diff --git a/app/calcul.py b/app/calcul.py
index 66d2476..81ad0a6 100644
--- a/app/calcul.py
+++ b/app/calcul.py
@@ -120,14 +120,14 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
cumul_chain_ok = False
jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok
- # Préconisations : répartit le manque restant à parts égales sur les demi-journées
- # restantes, dans la limite de la capacité de chaque créneau. La pause de midi
- # (matin_fin → reprise, 12:00 → 13:30 par défaut) est intouchable : la cible
- # matin ne dépasse jamais matin_fin, et le nominal matin qui n'y tient pas
- # bascule sur l'après-midi du même jour. Le matin démarre à l'arrivée visée
- # (sinon matin_debut) ; 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.
+ # 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.
for jour in jours:
jour["cible_matin"] = None
jour["cible_aprem"] = None
@@ -149,7 +149,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
if jour.get("aprem_entree") and not jour.get("aprem_sortie"):
restant_planif += max(0, now_min - hhmm_to_minutes(jour["aprem_entree"]))
- slots = [] # {jour, half, nominal, debut, cap} — cap = durée maximale du créneau
+ slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage
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
@@ -171,42 +171,62 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
surplus = max(0, matin_nominal - cap)
if surplus and not aprem_done:
aprem_nominal += surplus
- slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap),
- "debut": debut, "cap": cap, "cap_pref": cap})
+ # 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.
+ slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
if not aprem_done:
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
cap = max(0, 23 * 60 + 59 - debut)
cap_pref = min(cap, max(0, depart_vise_min - debut)) if depart_vise_min else cap
- slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal,
- "debut": debut, "cap": cap, "cap_pref": cap_pref})
+ # 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)
+ slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, "debut": debut,
+ "cap": cap, "cap_pref": cap_pref, "floor_room": floor_room})
- extra_total = max(0, restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
slots_restants = len(slots)
+ ecart_total = (restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
+ aprem_slots = [s for s in slots if s["half"] == "aprem"]
+ adjs = [0] * len(aprem_slots)
- # Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau,
- # en deux passes : d'abord dans le cadre visé (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.
- adjs = [0] * slots_restants
- left = extra_total
- for cap_key in ("cap_pref", "cap"):
+ # Répartition équitable de l'écart (retard ou avance), plafonnée par la capacité de
+ # chaque créneau du soir, en deux passes en cas de retard : d'abord dans le cadre visé
+ # (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).
+ 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"]]
+ 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))
+ for k, i in enumerate(open_idx):
+ give = min(aprem_slots[i][cap_key] - aprem_slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
+ adjs[i] += give
+ left -= give
+ elif ecart_total < 0:
+ left = -ecart_total
while left > 0:
- open_idx = [i for i, s in enumerate(slots) if adjs[i] < s[cap_key] - s["nominal"]]
+ 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 saturés à ce plafond : passe suivante, ou rattrapage impossible
+ break # tous les créneaux déjà au plancher : l'avance restante n'est pas plaçable
base, rem = divmod(left, len(open_idx))
for k, i in enumerate(open_idx):
- give = min(slots[i][cap_key] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
- adjs[i] += give
- left -= give
+ take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0))
+ adjs[i] -= take
+ left -= take
- for i, s in enumerate(slots):
- if adjs[i] <= 0:
+ for i, s in enumerate(aprem_slots):
+ if adjs[i] == 0:
continue # rythme normal suffit pour cette demi-journée, rien à signaler
sortie_min = s["debut"] + s["nominal"] + adjs[i]
- sortie_min = max(sortie_min, matin_fin_min if s["half"] == "matin" else aprem_fin_min)
- cible = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
- s["jour"]["cible_matin" if s["half"] == "matin" else "cible_aprem"] = cible
+ sortie_min = max(sortie_min, aprem_fin_min)
+ s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
+ extra_total = max(0, ecart_total)
+ avance_total = max(0, -ecart_total)
reste_min = max(0, restant)
return {
@@ -219,6 +239,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"reste": minutes_to_hhmm(reste_min),
"extra_min": extra_total,
"extra": minutes_to_hhmm(extra_total),
+ "avance_min": avance_total,
+ "avance": minutes_to_hhmm(avance_total),
"slots_restants": slots_restants,
"moyenne_extra_min": (extra_total // slots_restants) if slots_restants else 0,
"moyenne_extra": minutes_to_hhmm((extra_total // slots_restants) if slots_restants else 0),
diff --git a/app/templates/_soldes.html b/app/templates/_soldes.html
index 20ec771..c01b993 100644
--- a/app/templates/_soldes.html
+++ b/app/templates/_soldes.html
@@ -21,6 +21,10 @@
Reste à faire : {{ result.reste }}
sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }}
— à rattraper : (~+{{ result.moyenne_extra }} chacune)
+ {% elif result.avance_min > 0 %}
+ Reste à faire : {{ result.reste }}
+ sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }}
+ — avance à écouler : {{ result.avance }}, répartie sur les départs du soir pour finir la semaine à 0.
{% elif result.reste_min > 0 %}
Reste à faire : {{ result.reste }}
sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }}
diff --git a/app/tests/test_calcul.py b/app/tests/test_calcul.py
index 726dab9..cba79db 100644
--- a/app/tests/test_calcul.py
+++ b/app/tests/test_calcul.py
@@ -248,14 +248,63 @@ def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today
assert vendredi["cible_aprem"] == "19:38"
-def test_compute_week_cible_matin_possible_quand_la_plage_matin_a_de_la_marge(freeze_today):
- """Avec des plages personnalisées où la matinée obligatoire est plus longue que
- le nominal, une cible matin peut être proposée — mais jamais au-delà de
- matin_fin, pour que la pause (matin_fin → reprise) reste libre.
+def test_compute_week_avance_reduit_les_departs_du_soir_restants(freeze_today):
+ """Symétrique du rattrapage : une avance (travaillé plus que dû) doit réduire
+ les heures de départ du soir des jours restants, pas seulement rester
+ invisible — pour permettre de planifier un solde à 0 en fin de semaine.
+
+ Plages personnalisées sans report matin→aprem (capacité matin 240 = nominal).
+ Lundi +20 min d'avance, reste de la semaine vide : les 4 départs restants
+ sont chacun avancés de 5 min (18:00 nominal → 17:55).
+ """
+ plages = {
+ "matin_debut": "09:00", "matin_fin": "13:00",
+ "aprem_debut": "14:00", "aprem_fin": "17:00",
+ "pause_dejeuner_fin": "14:00",
+ }
+ heures_jour = 480 # 240 matin + 240 aprem
+ pointages = [
+ _pointage("2026-07-13", "09:00", "13:00", "14:00", "18:20"), # +20 min
+ _pointage("2026-07-14"),
+ _pointage("2026-07-15"),
+ _pointage("2026-07-16"),
+ _pointage("2026-07-17"),
+ ]
+ result = compute_week(pointages, [], heures_jour, plages)
+ assert result["extra_min"] == 0 # extra_min ne compte que le retard, pas l'avance
+ for jour in result["jours"][1:]:
+ assert jour["cible_matin"] is None
+ assert jour["cible_aprem"] == "17:55"
+
+
+def test_compute_week_avance_ne_descend_jamais_sous_aprem_fin(freeze_today):
+ """L'avance ne peut pas faire descendre un départ sous le plancher aprem_fin
+ (présence minimale obligatoire) : au-delà de la marge disponible, l'avance
+ restante n'est simplement pas placée (elle reste visible dans le solde).
+
+ 234 min nominal aprem (13:30 + 234 = 17:24), plancher 17:00 : seulement 24 min
+ de marge par jour. Avec 200 min d'avance sur un seul jour ouvert, la cible
+ plafonne à 17:00 au lieu de descendre à 17:24 - 200.
+ """
+ pointages = [
+ _pointage("2026-07-13", "09:00", "12:24", "13:30", "20:34"), # +200 min
+ _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"),
+ ]
+ result = compute_week(pointages, [], HEURES_JOUR_MIN)
+ vendredi = result["jours"][4]
+ assert vendredi["cible_aprem"] == "17:00"
+
+
+def test_compute_week_cible_matin_jamais_ajustee_meme_avec_de_la_marge(freeze_today):
+ """Le rattrapage hebdomadaire ne porte jamais sur le matin, même quand la
+ matinée obligatoire est plus longue que le nominal et aurait de la marge.
Matin 08:00→13:00 (capacité 300 > nominal 270). Vendredi en congé après-midi,
- 120 min de retard : le matin reçoit +30 max (plafond 13:00), le reste n'est
- pas plaçable. La pause 13:00→14:00 reste intacte.
+ 120 min de retard : le seul créneau ouvert (le matin) n'est pas ajustable, le
+ retard n'est pas plaçable ailleurs (l'après-midi est en congé).
"""
plages = {
"matin_debut": "08:00", "matin_fin": "13:00",
@@ -274,15 +323,14 @@ def test_compute_week_cible_matin_possible_quand_la_plage_matin_a_de_la_marge(fr
result = compute_week(pointages, conges, heures_jour, plages)
vendredi = result["jours"][4]
assert result["extra_min"] == 120
- # 08:00 + 270 nominal + 30 (saturé au plafond matin_fin) = 13:00
- assert vendredi["cible_matin"] == "13:00"
+ assert vendredi["cible_matin"] is None
assert vendredi["cible_aprem"] is None # congé
-def test_compute_week_arrivee_visee_etend_le_creneau_matin(freeze_today):
- """L'arrivée visée sert de début projeté du matin : en visant 08:00, le créneau
- matin gagne de la capacité (08:00→12:00 = 240 ≥ 234 nominal) et peut recevoir
- du rattrapage — toujours sans dépasser matin_fin.
+def test_compute_week_arrivee_visee_n_etend_pas_le_rattrapage_au_matin(freeze_today):
+ """L'arrivée visée sert de début projeté du matin (pour le calcul du surplus
+ basculant vers l'après-midi), mais n'ouvre jamais le matin au rattrapage
+ hebdomadaire — celui-ci reste réservé aux départs du soir.
"""
plages = {"arrivee_visee": "08:00"}
conges = [{"date": "2026-07-17", "type": "aprem"}]
@@ -296,8 +344,7 @@ def test_compute_week_arrivee_visee_etend_le_creneau_matin(freeze_today):
result = compute_week(pointages, conges, HEURES_JOUR_MIN, plages)
vendredi = result["jours"][4]
assert result["extra_min"] == 120
- # 08:00 + 234 nominal + 6 (saturé au plafond matin_fin) = 12:00
- assert vendredi["cible_matin"] == "12:00"
+ assert vendredi["cible_matin"] is None
def test_compute_week_depart_vise_plafond_souple(freeze_today):