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_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) "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 "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"]) matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"]) aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_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_travaille = 0
total_du = 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 jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok
# Préconisations : répartit le manque restant à parts égales sur les demi-journées # 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é # restantes, dans la limite de la capacité de chaque créneau. La pause de midi
# à la reprise de l'après-midi, l'après-midi à minuit). # (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: for jour in jours:
jour["cible_matin"] = None jour["cible_matin"] = None
jour["cible_aprem"] = None jour["cible_aprem"] = None
@ -154,30 +163,41 @@ 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 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 aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg
if not matin_done: if not matin_done:
debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else matin_debut_min debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else arrivee_visee_min
slots.append({"jour": jour, "half": "matin", "nominal": matin_nominal, reprise = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
"debut": debut, "cap": max(0, pause_dejeuner_fin_min - debut)}) 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: if not aprem_done:
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min 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, 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 extra_total = max(0, restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
slots_restants = len(slots) slots_restants = len(slots)
# Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau : # 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. # 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 adjs = [0] * slots_restants
left = extra_total left = extra_total
while left > 0: for cap_key in ("cap_pref", "cap"):
open_idx = [i for i, s in enumerate(slots) if adjs[i] < s["cap"] - s["nominal"]] while left > 0:
if not open_idx: open_idx = [i for i, s in enumerate(slots) if adjs[i] < s[cap_key] - s["nominal"]]
break # tous les créneaux saturés : rattrapage partiellement impossible cette semaine if not open_idx:
base, rem = divmod(left, len(open_idx)) break # tous les créneaux saturés à ce plafond : passe suivante, ou rattrapage impossible
for k, i in enumerate(open_idx): base, rem = divmod(left, len(open_idx))
give = min(slots[i]["cap"] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0)) for k, i in enumerate(open_idx):
adjs[i] += give give = min(slots[i][cap_key] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
left -= give adjs[i] += give
left -= give
for i, s in enumerate(slots): for i, s in enumerate(slots):
if adjs[i] <= 0: if adjs[i] <= 0:

View File

@ -599,6 +599,7 @@ def settings_plages_save(
matin_debut: str = Form(""), matin_fin: str = Form(""), matin_debut: str = Form(""), matin_fin: str = Form(""),
aprem_debut: str = Form(""), aprem_fin: str = Form(""), aprem_debut: str = Form(""), aprem_fin: str = Form(""),
pause_dejeuner_fin: str = Form(""), pause_dejeuner_fin: str = Form(""),
arrivee_visee: str = Form(""), depart_vise: str = Form(""),
): ):
user_id = _get_user(request) user_id = _get_user(request)
if not user_id: if not user_id:
@ -608,13 +609,20 @@ def settings_plages_save(
"matin_debut": matin_debut, "matin_fin": matin_fin, "matin_debut": matin_debut, "matin_fin": matin_fin,
"aprem_debut": aprem_debut, "aprem_fin": aprem_fin, "aprem_debut": aprem_debut, "aprem_fin": aprem_fin,
"pause_dejeuner_fin": pause_dejeuner_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) 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) return RedirectResponse("/settings?plages_error=1", status_code=303)
if not (fields["matin_debut"] < fields["matin_fin"] < fields["aprem_debut"] < fields["aprem_fin"]): 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) 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) 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) logger.info("réglages plages mis à jour: user_id=%r plages=%r", user_id, fields)
save_plages(user_id, fields) save_plages(user_id, fields)

View File

@ -12,7 +12,10 @@
<p class="settings-p"> <p class="settings-p">
Bornes de présence obligatoire matin/après-midi et heure de reprise par défaut de 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 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> </p>
{% if plages_saved %} {% if plages_saved %}
@ -37,6 +40,12 @@
</div> </div>
<label class="login-label" for="pause_dejeuner_fin">Reprise par défaut de l'après-midi</label> <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 }}"> <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> <button class="login-btn" type="submit">Enregistrer</button>
</form> </form>
</div> </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. 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é à 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 10:30) matin en cours depuis 09:00. Le matin s'arrête à la pause de midi
est plafonné par la pause déjeuner (max +36), donc 30/30 passe sans saturation. (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 = [ pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:54"), # -60 min _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) result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4] vendredi = result["jours"][4]
assert result["extra_min"] == 60 assert result["extra_min"] == 60
# matin : 09:00 + 234 nominal + 30 = 13:24 ; aprem : 13:30 + 234 + 30 = 17:54 # matin saturé au nominal (09:00→12:00), pas de cible ; aprem : 13:30 + 288 + 60 = 19:18
assert vendredi["cible_matin"] == "13:24" assert vendredi["cible_matin"] is None
assert vendredi["cible_aprem"] == "17:54" assert vendredi["cible_aprem"] == "19:18"
def test_compute_week_cible_matin_plafonnee_a_la_pause_dejeuner(freeze_today): def test_compute_week_cible_matin_ne_mange_jamais_la_pause_de_midi(freeze_today):
"""La cible matin ne dépasse jamais la reprise de l'après-midi : un créneau """La cible matin ne dépasse jamais matin_fin : la pause de midi (12:00→13:30)
saturé reçoit sa capacité max, le surplus reste non alloué (pas de cible absurde). 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é Vendredi en congé après-midi : seule sa matinée reste, plafonnée à 09:00→12:00
09:00→13:30 = 270, soit +36 max). Avec 120 min de retard, la cible est plafonnée (180 min, sous les 234 nominales). Avec 120 min de retard sur lundi, aucune
à 13:30 au lieu d'un impossible 14:54. 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"}] conges = [{"date": "2026-07-17", "type": "aprem"}]
pointages = [ 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) result = compute_week(pointages, conges, HEURES_JOUR_MIN)
vendredi = result["jours"][4] vendredi = result["jours"][4]
assert result["extra_min"] == 120 # 120 min de retard + 54 min de nominal matin qui ne tiennent pas avant la pause
assert vendredi["cible_matin"] == "13:30" # (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é assert vendredi["cible_aprem"] is None # congé
def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today): 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. 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 80 min de retard, vendredi entièrement vide : le matin est déjà saturé à son
plafonne à +36 — les 4 min restantes basculent sur l'après-midi (+44). 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 = [ pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:34"), # -80 min _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) result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4] vendredi = result["jours"][4]
assert result["extra_min"] == 80 assert result["extra_min"] == 80
# matin saturé : 09:00 + 270 = 13:30 ; aprem : 13:30 + 234 + 44 = 18:08 # matin saturé au nominal, pas de cible ; aprem : 13:30 + 288 + 80 = 19:38
assert vendredi["cible_matin"] == "13:30" assert vendredi["cible_matin"] is None
assert vendredi["cible_aprem"] == "18:08" 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): def test_compute_week_plages_personnalisees(freeze_today):