Respecte la pause déjeuner dans les préconisations et ajoute les heures visées

La cible matin ne dépasse plus jamais matin_fin, garantissant la pause de
midi ; le nominal qui déborde bascule sur l'après-midi du même jour.
Ajoute deux réglages optionnels par utilisateur (arrivée visée le matin,
départ visé le soir) qui orientent la répartition du rattrapage en deux
passes : d'abord dans le cadre visé, puis au-delà si nécessaire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Antoine
2026-07-18 11:57:46 +02:00
parent 45790b2b95
commit d3c991f8d6
4 changed files with 161 additions and 38 deletions

View File

@ -7,6 +7,8 @@ DEFAULT_PLAGES = {
"aprem_debut": "14:00", # présence obligatoire à partir de cette heure l'après-midi
"aprem_fin": "17:00", # présence obligatoire jusqu'à cette heure l'après-midi (plancher de sortie)
"pause_dejeuner_fin": "13:30", # reprise par défaut projetée tant que l'entrée aprem n'est pas saisie
"arrivee_visee": "", # arrivée du matin visée pour les préconisations (vide = matin_debut)
"depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun)
}
@ -75,6 +77,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
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
depart_vise_min = hhmm_to_minutes(plages["depart_vise"]) if plages.get("depart_vise") else None
total_travaille = 0
total_du = 0
@ -117,8 +121,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
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 (le matin est plafonné
# à la reprise de l'après-midi, l'après-midi à minuit).
# 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.
for jour in jours:
jour["cible_matin"] = None
jour["cible_aprem"] = None
@ -154,28 +163,39 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg
aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg
if not matin_done:
debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else matin_debut_min
slots.append({"jour": jour, "half": "matin", "nominal": matin_nominal,
"debut": debut, "cap": max(0, pause_dejeuner_fin_min - debut)})
debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else arrivee_visee_min
reprise = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
cap = max(0, min(matin_fin_min, reprise) - debut)
# Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi
# du même jour (s'il est encore ouvert), sinon dans le rattrapage global.
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})
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": max(0, 23 * 60 + 59 - debut)})
"debut": debut, "cap": cap, "cap_pref": cap_pref})
extra_total = max(0, restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
slots_restants = len(slots)
# Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau :
# le surplus d'un créneau saturé se reporte sur les autres.
# 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"):
while left > 0:
open_idx = [i for i, s in enumerate(slots) if adjs[i] < s["cap"] - s["nominal"]]
open_idx = [i for i, s in enumerate(slots) if adjs[i] < s[cap_key] - s["nominal"]]
if not open_idx:
break # tous les créneaux saturés : rattrapage partiellement impossible cette semaine
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(slots[i]["cap"] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
give = min(slots[i][cap_key] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
adjs[i] += give
left -= give

View File

@ -599,6 +599,7 @@ def settings_plages_save(
matin_debut: str = Form(""), matin_fin: str = Form(""),
aprem_debut: str = Form(""), aprem_fin: str = Form(""),
pause_dejeuner_fin: str = Form(""),
arrivee_visee: str = Form(""), depart_vise: str = Form(""),
):
user_id = _get_user(request)
if not user_id:
@ -608,13 +609,20 @@ def settings_plages_save(
"matin_debut": matin_debut, "matin_fin": matin_fin,
"aprem_debut": aprem_debut, "aprem_fin": aprem_fin,
"pause_dejeuner_fin": pause_dejeuner_fin,
# Heures visées, optionnelles (vide = comportement par défaut)
"arrivee_visee": arrivee_visee, "depart_vise": depart_vise,
}
if not all(_HHMM_RE.match(v) for v in fields.values()):
optionnels = {"arrivee_visee", "depart_vise"}
if not all(_HHMM_RE.match(v) for k, v in fields.items() if v or k not in optionnels):
logger.warning("réglages plages rejetés (format invalide): user_id=%r valeurs=%r", user_id, fields)
return RedirectResponse("/settings?plages_error=1", status_code=303)
if not (fields["matin_debut"] < fields["matin_fin"] < fields["aprem_debut"] < fields["aprem_fin"]):
logger.warning("réglages plages rejetés (ordre invalide): user_id=%r valeurs=%r", user_id, fields)
return RedirectResponse("/settings?plages_error=1", status_code=303)
if (fields["arrivee_visee"] and fields["arrivee_visee"] >= fields["matin_fin"]) or \
(fields["depart_vise"] and fields["depart_vise"] < fields["aprem_fin"]):
logger.warning("réglages plages rejetés (heures visées incohérentes): user_id=%r valeurs=%r", user_id, fields)
return RedirectResponse("/settings?plages_error=1", status_code=303)
logger.info("réglages plages mis à jour: user_id=%r plages=%r", user_id, fields)
save_plages(user_id, fields)

View File

@ -12,7 +12,10 @@
<p class="settings-p">
Bornes de présence obligatoire matin/après-midi et heure de reprise par défaut de
l'après-midi, utilisées pour les avertissements de saisie et les préconisations de
fin de demi-journée. Propres à ton compte — ne change rien pour les autres.
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 —
ne change rien pour les autres.
</p>
{% if plages_saved %}
@ -37,6 +40,12 @@
</div>
<label class="login-label" for="pause_dejeuner_fin">Reprise par défaut de l'après-midi</label>
<input id="pause_dejeuner_fin" class="login-input time-range-input" type="time" name="pause_dejeuner_fin" value="{{ plages.pause_dejeuner_fin }}">
<label class="login-label" for="arrivee_visee">Heures visées arrivée / départ (optionnel)</label>
<div class="settings-range">
<input id="arrivee_visee" class="login-input time-range-input" type="time" name="arrivee_visee" value="{{ plages.arrivee_visee }}">
<span class="settings-range-sep"></span>
<input id="depart_vise" class="login-input time-range-input" type="time" name="depart_vise" value="{{ plages.depart_vise }}" aria-label="Départ visé le soir">
</div>
<button class="login-btn" type="submit">Enregistrer</button>
</form>
</div>

View File

@ -180,8 +180,9 @@ def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today)
comme capacité restante — ce qui masquait le rattrapage pendant la journée.
Lundi en retard de 60 min, mardi-jeudi complets, vendredi (aujourd'hui, gelé à
10:30) matin en cours depuis 09:00. Les 60 min doivent être réparties : le matin
est plafonné par la pause déjeuner (max +36), donc 30/30 passe sans saturation.
10:30) matin en cours depuis 09:00. Le matin s'arrête à la pause de midi
(nominal plafonné à 12:00, l'excédent bascule sur l'après-midi) : les 60 min
de rattrapage vont entièrement sur l'après-midi.
"""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:54"), # -60 min
@ -193,18 +194,19 @@ def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today)
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert result["extra_min"] == 60
# matin : 09:00 + 234 nominal + 30 = 13:24 ; aprem : 13:30 + 234 + 30 = 17:54
assert vendredi["cible_matin"] == "13:24"
assert vendredi["cible_aprem"] == "17:54"
# matin saturé au nominal (09:00→12:00), pas de cible ; aprem : 13:30 + 288 + 60 = 19:18
assert vendredi["cible_matin"] is None
assert vendredi["cible_aprem"] == "19:18"
def test_compute_week_cible_matin_plafonnee_a_la_pause_dejeuner(freeze_today):
"""La cible matin ne dépasse jamais la reprise de l'après-midi : un créneau
saturé reçoit sa capacité max, le surplus reste non alloué (pas de cible absurde).
def test_compute_week_cible_matin_ne_mange_jamais_la_pause_de_midi(freeze_today):
"""La cible matin ne dépasse jamais matin_fin : la pause de midi (12:00→13:30)
reste libre, même quand le retard ne peut pas être rattrapé ailleurs.
Vendredi en congé après-midi : seule sa matinée reste (nominal 234, capacité
09:00→13:30 = 270, soit +36 max). Avec 120 min de retard, la cible est plafonnée
à 13:30 au lieu d'un impossible 14:54.
Vendredi en congé après-midi : seule sa matinée reste, plafonnée à 09:00→12:00
(180 min, sous les 234 nominales). Avec 120 min de retard sur lundi, aucune
cible n'est proposée (le créneau est déjà saturé) plutôt qu'une sortie à 13:30
qui supprimerait la pause ; le manque non plaçable reste visible dans extra.
"""
conges = [{"date": "2026-07-17", "type": "aprem"}]
pointages = [
@ -216,17 +218,20 @@ def test_compute_week_cible_matin_plafonnee_a_la_pause_dejeuner(freeze_today):
]
result = compute_week(pointages, conges, HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert result["extra_min"] == 120
assert vendredi["cible_matin"] == "13:30"
# 120 min de retard + 54 min de nominal matin qui ne tiennent pas avant la pause
# (et ne peuvent pas basculer sur l'après-midi, en congé)
assert result["extra_min"] == 174
assert vendredi["cible_matin"] is None
assert vendredi["cible_aprem"] is None # congé
def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today):
"""Quand un créneau matin sature (plafond pause déjeuner), le surplus doit se
"""Quand le créneau matin sature (plafond pause de midi), le rattrapage doit se
reporter sur les créneaux encore ouverts au lieu d'être perdu.
80 min de retard sur 2 demi-journées de vendredi : réparti 40/40, mais le matin
plafonne à +36 — les 4 min restantes basculent sur l'après-midi (+44).
80 min de retard, vendredi entièrement vide : le matin est déjà saturé à son
plafond 09:00→12:00 (nominal 180, excédent 54 basculé sur l'après-midi qui
passe à 288), donc tout le rattrapage va sur l'après-midi.
"""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:34"), # -80 min
@ -238,9 +243,90 @@ def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert result["extra_min"] == 80
# matin saturé : 09:00 + 270 = 13:30 ; aprem : 13:30 + 234 + 44 = 18:08
assert vendredi["cible_matin"] == "13:30"
assert vendredi["cible_aprem"] == "18:08"
# matin saturé au nominal, pas de cible ; aprem : 13:30 + 288 + 80 = 19:38
assert vendredi["cible_matin"] is None
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.
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.
"""
plages = {
"matin_debut": "08:00", "matin_fin": "13:00",
"aprem_debut": "14:00", "aprem_fin": "18:00",
"pause_dejeuner_fin": "14:00",
}
heures_jour = 540
conges = [{"date": "2026-07-17", "type": "aprem"}]
pointages = [
_pointage("2026-07-13", "08:00", "13:00", "14:00", "16:00"), # -120 min
_pointage("2026-07-14", "08:00", "13:00", "14:00", "18:00"),
_pointage("2026-07-15", "08:00", "13:00", "14:00", "18:00"),
_pointage("2026-07-16", "08:00", "13:00", "14:00", "18:00"),
_pointage("2026-07-17"),
]
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_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.
"""
plages = {"arrivee_visee": "08:00"}
conges = [{"date": "2026-07-17", "type": "aprem"}]
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "15:54"), # -120 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, 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"
def test_compute_week_depart_vise_plafond_souple(freeze_today):
"""Le départ visé concentre d'abord le rattrapage sur les créneaux qui tiennent
avant cette heure ; il n'est dépassé que si ça ne tient nulle part (plafond souple).
Vendredi matin pointé 09:00→12:00, son après-midi reste (nominal 234) ; lundi
suivant vide (matin 180 saturé, aprem 288). Rattrapage : 54 min. Sans départ
visé, réparti 27/27. Avec départ visé 18:00 : l'aprem de vendredi (qui a de la
marge avant 18:00) absorbe d'abord 36, puis la 2e passe répartit le reste 9/9.
"""
plages = {"depart_vise": "18:00"}
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"),
_pointage("2026-07-20"), # lundi suivant, vide
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, plages)
vendredi = result["jours"][4]
lundi = result["jours"][5]
assert result["extra_min"] == 54
# vendredi aprem : 13:30 + 234 + (36 + 9) = 18:09 ; lundi aprem : 13:30 + 288 + 9 = 18:27
# (sans départ visé, lundi finirait à 18:45)
assert vendredi["cible_aprem"] == "18:09"
assert lundi["cible_matin"] is None
assert lundi["cible_aprem"] == "18:27"
def test_compute_week_plages_personnalisees(freeze_today):