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

View File

@ -61,7 +61,7 @@
{% elif result.avance_min > 0 %}
Reste à faire : <strong>{{ result.reste }}</strong>
sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }}
— avance à écouler : <strong>{{ result.avance }}</strong>, répartie sur les départs du soir pour finir la semaine à 0.
— avance à écouler : <strong>{{ result.avance }}</strong>, répartie sur les départs du soir, puis arrivées et retours de midi, pour finir la semaine à 0.
{% elif result.reste_min > 0 %}
Reste à faire : <strong>{{ result.reste }}</strong>
sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }}

View File

@ -17,7 +17,10 @@
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 —
avant la fin de la présence matin). En avance : départs du soir raccourcis
(jamais sous la fin de la présence après-midi), puis arrivée du matin retardée
(jamais après le début de la présence matin), puis retour de midi retardé
(jamais après le début de la présence après-midi). Propres à ton compte —
ne change rien pour les autres.
</p>

View File

@ -505,3 +505,169 @@ def test_jour_en_cours_ne_vise_pas_une_heure_passee(freeze_today):
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"
# ── Avance : arrivées et retours de midi retardés en secours des départs ──────
def test_avance_ecoulee_sur_arrivee_puis_retour_de_midi(freeze_today):
"""Jeudi fini à +48 avec une sortie nominale déjà au plancher aprem_fin
(17:00) : l'avance ne tenait nulle part et les cibles restaient au rythme
nominal. Elle s'écoule désormais en retardant l'arrivée du matin (08:30 →
09:00, début de Présence matin) puis le retour de midi (13:30 → 13:48,
sous le début de Présence après-midi 14:00)."""
plages = {"arrivee_visee": "08:30"}
pointages = [
_pointage("2026-07-13", "08:30", "12:00", "13:30", "17:00"),
_pointage("2026-07-14", "08:30", "12:00", "13:30", "17:00"),
_pointage("2026-07-15", "08:30", "12:00", "13:30", "17:00"),
_pointage("2026-07-16", "08:30", "12:00", "13:30", "17:48"), # +48
_pointage("2026-07-17"),
_pointage("2026-07-20"),
]
conges = [{"date": "2026-07-17", "type": "jour"}]
result = compute_week(pointages, conges, 420, plages) # 7h00/jour : sortie nominale 17:00
lundi = result["jours"][5]
assert result["avance_min"] == 48
assert lundi["cible_matin_entree"] == "09:00" # arrivée retardée jusqu'à Présence matin
assert lundi["cible_matin"] == "12:00"
assert lundi["cible_aprem_entree"] == "13:48" # retour de midi retardé de 18 min
assert lundi["cible_aprem"] == "17:00" # déjà au plancher, rien à avancer
def test_avance_par_defaut_passe_par_le_retour_de_midi(freeze_today):
"""Plages par défaut (arrivée visée = début de Présence matin, rien à
retarder côté arrivée) : l'avance restante après les départs du soir
s'écoule sur le retour de midi, plafonné au début de Présence après-midi."""
pointages = [
_pointage("2026-07-13", "09:00", "12:00", "13:30", "17:30"),
_pointage("2026-07-14", "09:00", "12:00", "13:30", "17:30"),
_pointage("2026-07-15", "09:00", "12:00", "13:30", "17:30"),
_pointage("2026-07-16", "09:00", "12:00", "13:30", "18:10"), # +40
_pointage("2026-07-17"),
_pointage("2026-07-20"),
]
conges = [{"date": "2026-07-17", "type": "jour"}]
result = compute_week(pointages, conges, 420)
lundi = result["jours"][5]
assert result["avance_min"] == 40
assert lundi["cible_matin_entree"] == "09:00" # rien à retarder (visée = Présence matin)
assert lundi["cible_aprem_entree"] == "13:40" # retour de midi retardé de 10 min
assert lundi["cible_aprem"] == "17:00" # départ réduit de 30 min puis plafonné
# ── Demi-journée en cours saturée (temps écoulé ≥ nominal) ────────────────────
class _Freezer:
"""Fige calcul.datetime à une date/heure donnée (2026-07-17 = vendredi)."""
@staticmethod
def freeze(monkeypatch, heure, minute):
import calcul as calcul_module
class FrozenDatetime(calcul_module.datetime):
_frozen = calcul_module.datetime(2026, 7, 17, heure, minute)
@classmethod
def today(cls):
return cls._frozen
monkeypatch.setattr(calcul_module, "datetime", FrozenDatetime)
def test_en_cours_sature_matin_pas_de_double_comptage(monkeypatch):
"""Régression : vendredi matin en cours depuis 09:00 sans sortie, il est
13:45 (sortie de midi oubliée). Le temps écoulé (285 min) dépasse la
capacité du créneau matin (180 min) : il était compté deux fois — une fois
dans le travaillé, une fois réintégré au plan — si bien que suivre les
cibles sur-livrait de 105 min. Désormais le créneau saturé propose
« sortie maintenant », ne réintègre rien, et l'après-midi est allégé
d'autant : suivi des cibles → solde 0 (à ~12 min près bornées par les
plages : reprise ≥ maintenant, plancher 17:00)."""
_Freezer.freeze(monkeypatch, 13, 45)
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17", matin_entree="09:00"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
# Sortie du matin = maintenant (285 min déjà faites, créneau saturé)
assert vendredi["cible_matin"] == "13:45"
# L'après-midi absorbe l'excédent du matin : départ au plancher 17:00
# (nominal 288 − 78 de marge) et reprise retardée à maintenant.
assert vendredi["cible_aprem_entree"] == "13:45"
assert vendredi["cible_aprem"] == "17:00"
# Suivre les cibles ne sur-livre plus de 105 min : reste ≤ 12 min
# (le solde résiduel vient des bornes de plages, pas du calcul).
suivis = [dict(p) for p in pointages]
suivis[4] = _pointage("2026-07-17", "09:00", vendredi["cible_matin"],
vendredi["cible_aprem_entree"], vendredi["cible_aprem"])
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
assert 0 <= apres["solde_min"] <= 12
def test_en_cours_sature_aprem_propose_sortie_immediate(monkeypatch):
"""Régression : vendredi après-midi en cours depuis 13:30 sans sortie, il est
19:30 — le nominal aprem (234) est dépassé de 126 min. L'écart affichait un
retard fantôme de 54 min ; désormais l'avance réelle (72 min) est montrée et
la seule cible cohérente est « sortir maintenant »."""
_Freezer.freeze(monkeypatch, 19, 30)
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17", "09:00", "12:00", aprem_entree="13:30"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert result["extra_min"] == 0 # plus de retard fantôme
assert result["avance_min"] == 72 # l'excédent déjà travaillé, affiché tel quel
assert vendredi["cible_aprem"] == "19:30" # sortir maintenant
def test_en_cours_non_sature_reste_inchange(monkeypatch):
"""Un créneau en cours classique (écoulé < nominal) garde le comportement
historique : le temps écoulé est réintégré au plan, le rattrapage du
retard pousse la sortie du soir au-delà du nominal."""
_Freezer.freeze(monkeypatch, 14, 20)
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:24"), # -90
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17", "09:00", "12:00", aprem_entree="13:30"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert result["extra_min"] == 144
assert vendredi["cible_aprem"] == "19:48"
suivis = [dict(p) for p in pointages]
suivis[4] = _pointage("2026-07-17", "09:00", "12:00", "13:30", vendredi["cible_aprem"])
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
assert apres["solde_min"] == 0
def test_entree_apres_fin_matin_le_surplus_ne_double_compte_pas(monkeypatch):
"""Régression : entrée du matin pointée à 12:30 (après la fin de Présence
matin), sans sortie, il est 13:45. Aucun créneau matin n'existe (capacité
nulle) : le temps écoulé (75 min) ne doit pas être réintégré au plan, sinon
l'après-midi du même jour se retrouve allongé de 75 min en trop. Le surplus
matin (234) bascule sur l'après-midi, réduit de ce qui est déjà fait."""
_Freezer.freeze(monkeypatch, 13, 45)
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17", matin_entree="12:30"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert vendredi["cible_matin"] == "13:45" # sortie immédiate de l'entrée ouverte
suivis = [dict(p) for p in pointages]
suivis[4] = _pointage("2026-07-17", "12:30", vendredi["cible_matin"],
vendredi["cible_aprem_entree"], vendredi["cible_aprem"])
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
assert apres["solde_min"] == 0