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

@ -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):