Astreinte : durée réelle arrondie au quart d'heure supérieur

Le décompte ne compte plus les quarts d'horloge (:00, :15, :30, :45)
entamés mais la durée réelle de chaque intervention arrondie au quart
d'heure supérieur (2, 5 ou 12 min → 15 ; 22 ou 28 → 30), même si
l'arrondi ne tombe pas juste sur l'horloge. Les minutes communes à deux
interventions restent comptées une seule fois, le repos de 11h s'appuie
sur la durée arrondie, et l'interface/README expliquent la nouvelle
méthode.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-09 20:12:39 +02:00
parent 5506f1ba39
commit 791b48b320
7 changed files with 142 additions and 126 deletions

View File

@ -1,4 +1,4 @@
"""Astreinte : événements, décompte par tranches de 15 min, repos de 11h et retour décalé."""
"""Astreinte : événements, durée réelle arrondie au quart d'heure supérieur, repos de 11h et retour décalé."""
from calcul import (
compute_week, decompte_astreinte, heures_dues, heures_travaillees, hhmm_to_minutes,
jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, fenetre_astreinte_jour,
@ -117,7 +117,7 @@ def test_retour_fin_du_soir_sur_le_jour_d_astreinte():
def test_retour_fin_hors_quart_arrondie_au_quart_entame():
"""Event 21:00→23:37 : le quart d'heure entamé est travaillé, fin comptée
"""Event 21:00→23:37 : durée réelle 157 min arrondie à 165, fin comptée
23:45 → repos terminé 10:45 le lendemain."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "21:00", "fin": "23:37"}])}
@ -125,7 +125,8 @@ def test_retour_fin_hors_quart_arrondie_au_quart_entame():
def test_retour_fin_nocturne_hors_quart_arrondie():
"""Event 23:00→01:07 (à cheval sur minuit) : fin comptée 01:15 → retour 12:15."""
"""Event 23:00→01:07 (à cheval sur minuit) : durée 127 min arrondie à 135,
fin comptée 01:15 → retour 12:15."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "23:00", "fin": "01:07"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60 + 15
@ -169,25 +170,36 @@ def test_retour_fin_tot_ne_contraint_rien():
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
# ── Décompte astreinte : tranches de 15 min entamées, par règle ───────────────
# ── Décompte astreinte : durée réelle arrondie au quart supérieur, par règle ───
def test_decompte_tranche_entamee_comptee_entiere():
"""22:07→22:20 touche les tranches alignées 22:00 et 22:15 : comptées entières."""
def test_decompte_duree_reelle_arrondie_au_quart_superieur():
"""22:07→22:20 = 13 min réelles → arrondies à 15 min (et non aux tranches
d'horloge 22:00 et 22:15 qui auraient donné 30)."""
d = decompte_astreinte("2026-07-13", [{"debut": "22:07", "fin": "22:20"}])
assert d["tranches"] == 2 # tranches 22:00 + 22:15 entamées
assert d["total_min"] == 2 * PAS_ASTREINTE_MIN # 30
assert d["total_h"] == "0h30"
assert d["tranches"] == 1 # 15 min = 1 quart d'heure
assert d["total_min"] == PAS_ASTREINTE_MIN
assert d["total_h"] == "0h15"
assert d["categories"][0]["nom"] == "Nuit" # lundi 22:xx → Nuit ×1,5
assert d["total_pondere_min"] == 45 # 30 × 1,5
assert d["total_pondere_min"] == 23 # 15 × 1,5
def test_decompte_arrondi_examples_minutes_reelles():
"""2, 5 ou 12 minutes réelles → 15 min ; 22 ou 28 → 30 min — même si
l'arrondi ne tombe pas juste sur un quart d'heure de l'horloge."""
for deb, fin, attendu in [("22:00", "22:02", 15), ("22:00", "22:05", 15),
("22:00", "22:12", 15), ("22:00", "22:22", 30),
("22:00", "22:28", 30), ("22:39", "23:49", 75)]:
d = decompte_astreinte("2026-07-13", [{"debut": deb, "fin": fin}])
assert d["total_min"] == attendu, f"{deb} → {fin}"
def test_decompte_arrondi_des_bornes():
"""09:50→10:05 touche les tranches 09:45 et 10:00 : 2 tranches en Journée ×1."""
"""09:50→10:05 = 15 min réelles → 15 min, en Journée ×1."""
d = decompte_astreinte("2026-07-13", [{"debut": "09:50", "fin": "10:05"}])
assert d["tranches"] == 2 # tranches 09:45 + 10:00
assert d["total_min"] == 30
assert d["tranches"] == 1
assert d["total_min"] == 15
assert d["categories"][0]["nom"] == "Journée" # lundi en journée ×1
assert d["total_pondere_min"] == 30
assert d["total_pondere_min"] == 15
def test_decompte_evenement_a_cheval_sur_minuit():
@ -218,36 +230,33 @@ def test_decompte_vendredi_vers_samedi_reste_en_nuit():
def test_decompte_chevauchement_dedouble():
"""10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent
qu'une fois, attribuées au premier événement ; les lignes somment au total."""
"""10:00→10:20 et 10:10→10:30 se recouvrent : les minutes réelles communes
ne comptent qu'une fois (attribuées au premier événement) ; chaque
intervention est arrondie sur ses seules minutes non déjà comptées."""
d = decompte_astreinte("2026-07-13", [
{"debut": "10:00", "fin": "10:20"},
{"debut": "10:10", "fin": "10:30"},
])
assert len(d["events"]) == 2
assert d["events"][0]["tranches"] == 2 # tranches 10:00 + 10:15
assert d["events"][0]["min"] == 30
assert d["events"][1]["tranches"] == 0 # les deux tranches déjà comptées
assert d["events"][1]["min"] == 0
assert d["tranches"] == 2 # union dédoublonnée
assert d["total_min"] == 30
assert d["events"][0]["min"] == 30 # 20 min réelles → arrondies à 30
assert d["events"][1]["min"] == 15 # 10 min nouvelles → arrondies à 15
assert d["total_min"] == 45
assert sum(e["min"] for e in d["events"]) == d["total_min"]
assert d["categories"][0]["nom"] == "Journée"
def test_decompte_quart_partage_entre_interventions():
"""Deux interventions qui se suivent et finissent/débutent dans le même quart :
le quart n'est compté qu'une fois (attribué à la première), les lignes
d'événements somment au total du jour."""
def test_decompte_minutes_partagees_entre_interventions():
"""Deux interventions qui se chevauchent : les minutes réelles communes ne
sont comptées qu'une fois (attribuées à la première) ; chaque ligne est
arrondie au quart supérieur et les événements somment au total du jour."""
d = decompte_astreinte("2026-07-13", [
{"debut": "22:39", "fin": "23:05"},
{"debut": "23:02", "fin": "23:49"},
])
assert d["events"][0]["tranches"] == 3 # 22:30 + 22:45 + 23:00
assert d["events"][1]["tranches"] == 3 # 23:15 + 23:30 + 23:45 (23:00 déjà compté)
assert sum(e["tranches"] for e in d["events"]) == d["tranches"] == 6
assert d["total_min"] == 90
assert d["total_pondere_min"] == 135 # 6 × 15 × 1,5 (Nuit)
assert d["events"][0]["min"] == 30 # 26 min réelles → 30
assert d["events"][1]["min"] == 45 # 44 min nouvelles → 45 (3 min communes)
assert d["total_min"] == 75
assert d["total_pondere_min"] == 113 # 75 × 1,5 (Nuit)
def test_decompte_event_incomplet_ignore():