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)
|
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:
|
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).
|
"""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 à
|
# 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
|
# é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
|
# restantes, pour viser un solde à 0 en fin de semaine. En cas de retard, si
|
||||||
# ajusté : la pause de midi (matin_fin → reprise, 12:00 → 13:30 par défaut) est
|
# les départs du soir ne suffisent pas (saturés à minuit), deux leviers
|
||||||
# intouchable, et le nominal matin qui n'y tient pas bascule sur l'après-midi du
|
# supplémentaires entrent en jeu dans l'ordre, bornés par les plages de
|
||||||
# même jour. L'après-midi vise le départ du soir configuré (plafond souple :
|
# présence des préférences : avancer les arrivées du matin (jamais avant le
|
||||||
# dépassé seulement si le rattrapage ne tient nulle part avant), avec minuit
|
# début de « Présence matin », l'heure de retour après repos, ni avant
|
||||||
# comme limite dure en cas de retard, ou aprem_fin comme plancher en cas d'avance.
|
# 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:
|
for jour in jours:
|
||||||
jour["cible_matin"] = None
|
jour["cible_matin"] = None
|
||||||
jour["cible_aprem"] = 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
|
# (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,
|
# 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).
|
# sans jamais descendre sous le plancher aprem_fin (une seule passe, pas de plafond souple).
|
||||||
|
left = 0
|
||||||
if ecart_total > 0:
|
if ecart_total > 0:
|
||||||
left = ecart_total
|
left = ecart_total
|
||||||
for cap_key in ("cap_pref", "cap"):
|
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
|
adjs[i] += give
|
||||||
left -= give
|
left -= give
|
||||||
elif ecart_total < 0:
|
elif ecart_total < 0:
|
||||||
left = -ecart_total
|
left_avance = -ecart_total
|
||||||
while left > 0:
|
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 -adjs[i] < s["floor_room"]]
|
||||||
if not open_idx:
|
if not open_idx:
|
||||||
break # tous les créneaux déjà au plancher : l'avance restante n'est pas plaçable
|
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):
|
for k, i in enumerate(open_idx):
|
||||||
take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0))
|
take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0))
|
||||||
adjs[i] -= take
|
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:
|
def _en_cours(jour, half: str) -> bool:
|
||||||
"""True si cette demi-journée est déjà commencée aujourd'hui (entrée pointée,
|
"""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")
|
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é
|
# 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:
|
for s in slots:
|
||||||
if s["half"] != "matin":
|
if s["half"] != "matin":
|
||||||
continue
|
continue
|
||||||
@ -874,7 +939,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
|||||||
sortie_min = s["debut"] + s["nominal"]
|
sortie_min = s["debut"] + s["nominal"]
|
||||||
if _en_cours(s["jour"], "matin"):
|
if _en_cours(s["jour"], "matin"):
|
||||||
sortie_min = max(sortie_min, now_min)
|
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_entree"] = entree
|
||||||
s["jour"]["cible_matin"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
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)
|
sortie_min = max(s["debut"] + s["nominal"] + adjs[i], aprem_fin_min)
|
||||||
if _en_cours(s["jour"], "aprem"):
|
if _en_cours(s["jour"], "aprem"):
|
||||||
sortie_min = max(sortie_min, now_min)
|
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_entree"] = entree
|
||||||
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,10 @@
|
|||||||
l'après-midi, utilisées pour les avertissements de saisie et les préconisations de
|
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
|
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
|
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.
|
ne change rien pour les autres.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
result = compute_week(pointages, [], HEURES_JOUR_MIN, plages)
|
||||||
jeudi = result["jours"][3]
|
jeudi = result["jours"][3]
|
||||||
assert jeudi["cible_aprem"] == "17:19"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user