La saisie astreinte devient une liste d'événements (début, fin, description,
nombre illimité, fin antérieure au début = intervention à cheval sur minuit).
Ces heures ne comptent jamais dans la semaine : elles sont décomptées à part
par pas de 15 minutes, toute tranche entamée comptant en totalité, et rangées
dans des compteurs selon le jour et l'heure (Nuit ×1,5, Journée semaine ×1,
Samedi ×1,5, Dimanche/férié ×2 ; jours fériés français calculés
automatiquement, règles ajustables dans config.json via astreinte_facteurs).
Le détail par événement et les totaux pondérés s'affichent sous le bandeau de
la semaine, et les exports .ods gagnent les colonnes Astreinte brute et
pondérée. Le retour au bureau (fin du dernier événement + 11h de repos) est
désormais dérivé des événements.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
390 lines
18 KiB
Python
390 lines
18 KiB
Python
"""Astreinte : événements, décompte par tranches de 15 min, 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,
|
||
retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN,
|
||
)
|
||
import models
|
||
|
||
HEURES_JOUR_MIN = 468 # 7h48
|
||
PLAGES = dict(DEFAULT_PLAGES)
|
||
|
||
|
||
def _pointage(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None,
|
||
astreinte_events=None):
|
||
return {
|
||
"date": date,
|
||
"matin_entree": matin_entree, "matin_sortie": matin_sortie,
|
||
"aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie,
|
||
"astreinte_events": astreinte_events,
|
||
}
|
||
|
||
|
||
# ── Qualification ─────────────────────────────────────────────────────────────
|
||
|
||
def test_jour_astreinte_qualifie():
|
||
astreintes = ["2026-07-14"]
|
||
assert jour_astreinte_qualifie("2026-07-14", astreintes) # jour d'astreinte
|
||
assert jour_astreinte_qualifie("2026-07-15", astreintes) # lendemain
|
||
assert not jour_astreinte_qualifie("2026-07-13", astreintes)
|
||
assert not jour_astreinte_qualifie("2026-07-15", [])
|
||
|
||
|
||
# ── Heures dues : exclusion avant l'heure de retour ───────────────────────────
|
||
|
||
def test_heures_dues_retour_avant_ouverture_sans_effet():
|
||
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("08:30")) == HEURES_JOUR_MIN
|
||
|
||
|
||
def test_heures_dues_retour_midi_retire_la_presence_du_matin():
|
||
# 13:00 → plage matin 09:00-12:00 (180 min) retirée du décompte
|
||
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("13:00")) == HEURES_JOUR_MIN - 180
|
||
|
||
|
||
def test_heures_dues_retour_apres_midi_retire_matin_et_partie_aprem():
|
||
# 15:00 → matin (180) + 14:00→15:00 (60)
|
||
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("15:00")) == HEURES_JOUR_MIN - 240
|
||
|
||
|
||
def test_heures_dues_conge_matin_pas_de_double_exclusion():
|
||
conges = [{"date": "2026-07-13", "type": "matin"}]
|
||
# base = demi-journée aprem (234) ; le matin déjà en congé n'est pas décompté deux fois
|
||
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("13:00")) == 234
|
||
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("15:00")) == 234 - 60
|
||
|
||
|
||
def test_exclusion_retour_sans_retour():
|
||
assert exclusion_retour(set(), None, PLAGES) == (0, 0)
|
||
|
||
|
||
# ── Heures travaillées : la présence avant le retour ne compte pas ────────────
|
||
|
||
def test_heures_travaillees_presence_avant_retour_non_comptee():
|
||
p = _pointage("2026-07-13", "09:00", "12:00", "14:00", "18:00")
|
||
# matin 09:00→12:00 entièrement avant le retour 13:00 → 0 ; aprem 14:00→18:00 → 240
|
||
assert heures_travaillees(p, hhmm_to_minutes("13:00")) == 240
|
||
assert heures_travaillees(p) == 180 + 240
|
||
|
||
|
||
def test_heures_travaillees_entree_chevauchant_le_retour():
|
||
p = _pointage("2026-07-13", "08:00", "12:00")
|
||
# entrée décalée à 10:30 → 90 min comptées
|
||
assert heures_travaillees(p, hhmm_to_minutes("10:30")) == 90
|
||
|
||
|
||
# ── Retour au plus tôt après 11h de repos (depuis le dernier événement) ───────
|
||
|
||
def test_retour_fin_nocturne_sur_le_lendemain():
|
||
"""Event 01:00→02:00 saisi sur le lendemain de l'astreinte → retour 13:00."""
|
||
p_by_date = {"2026-07-15": _pointage("2026-07-15",
|
||
astreinte_events=[{"debut": "01:00", "fin": "02:00"}])}
|
||
# 02:00 + 11h = 13:00
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60
|
||
|
||
|
||
def test_retour_activite_matinale_sur_le_jour_meme():
|
||
"""Event 05:00→06:00 sur le jour d'astreinte : l'activité s'est réellement
|
||
terminée à 06:00 ce jour-là → repos jusqu'à 17:00 le même jour."""
|
||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||
astreinte_events=[{"debut": "05:00", "fin": "06:00"}])}
|
||
assert retour_apres_repos("2026-07-14", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
|
||
|
||
|
||
def test_retour_fin_du_soir_sur_le_jour_d_astreinte():
|
||
"""Event 21:00→23:30 sur le jour d'astreinte → repos terminé 10:30 le lendemain."""
|
||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||
astreinte_events=[{"debut": "21:00", "fin": "23:30"}])}
|
||
# 23:30 + 11h = 10:30 le lendemain
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 30
|
||
|
||
|
||
def test_retour_event_a_cheval_sur_minuit_saisi_la_veille():
|
||
"""Event 23:00→01:00 (fin < début) saisi la veille : fin réelle 01:00 le jour
|
||
même → 01:00 + 11h = 12:00."""
|
||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||
astreinte_events=[{"debut": "23:00", "fin": "01:00"}])}
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60
|
||
|
||
|
||
def test_retour_nuit_d_astreinte_finissant_au_matin():
|
||
"""Event 23:00→06:00 (à cheval sur minuit) sur le jour d'astreinte : fin réelle
|
||
06:00 le lendemain → retour 17:00 le lendemain."""
|
||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||
astreinte_events=[{"debut": "23:00", "fin": "06:00"}])}
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
|
||
|
||
|
||
def test_retour_la_plus_tardive_des_deux_fins_gagne():
|
||
"""Fins veille 23:30 (retour 10:30) ET jour 02:00 (retour 13:00) : la plus tardive gagne."""
|
||
p_by_date = {
|
||
"2026-07-14": _pointage("2026-07-14", astreinte_events=[{"debut": "21:00", "fin": "23:30"}]),
|
||
"2026-07-15": _pointage("2026-07-15", astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
|
||
}
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60
|
||
|
||
|
||
def test_retour_event_sans_astreinte_ignore():
|
||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||
astreinte_events=[{"debut": "21:00", "fin": "23:30"}])}
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, []) is None
|
||
|
||
|
||
def test_retour_fin_tot_ne_contraint_rien():
|
||
"""Event 17:15→17:30 → repos terminé 04:30, avant l'ouverture : pas de contrainte."""
|
||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||
astreinte_events=[{"debut": "17:15", "fin": "17:30"}])}
|
||
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 ───────────────
|
||
|
||
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."""
|
||
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["categories"][0]["nom"] == "Nuit" # lundi 22:xx → Nuit ×1,5
|
||
assert d["total_pondere_min"] == 45 # 30 × 1,5
|
||
|
||
|
||
def test_decompte_arrondi_des_bornes():
|
||
"""09:50→10:05 touche les tranches 09:45 et 10:00 : 2 tranches 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["categories"][0]["nom"] == "Journée" # lundi en journée ×1
|
||
assert d["total_pondere_min"] == 30
|
||
|
||
|
||
def test_decompte_evenement_a_cheval_sur_minuit():
|
||
"""Mardi 14/07 (férié), 23:00→01:00 : 8 tranches, tout en Nuit ×1,5."""
|
||
d = decompte_astreinte("2026-07-14", [{"debut": "23:00", "fin": "01:00"}])
|
||
assert d["tranches"] == 8 # 23:00→01:00 = 120 min = 8 × 15
|
||
assert d["total_min"] == 120
|
||
assert d["categories"][0]["nom"] == "Nuit"
|
||
assert d["total_pondere_min"] == 180 # 120 × 1,5
|
||
|
||
|
||
def test_decompte_jour_ferie_en_journee():
|
||
"""14 juillet : la journée compte en « Dimanche / férié » ×2."""
|
||
d = decompte_astreinte("2026-07-14", [{"debut": "10:00", "fin": "12:00"}])
|
||
assert d["total_min"] == 120 # 2h = 8 tranches
|
||
assert d["categories"][0]["nom"] == "Dimanche / férié"
|
||
assert d["total_pondere_min"] == 240 # 120 × 2
|
||
assert d["total_pondere_h"] == "4h00"
|
||
|
||
|
||
def test_decompte_vendredi_vers_samedi_reste_en_nuit():
|
||
"""23:00→02:00 le vendredi : 12 tranches, toutes en Nuit (même après minuit)."""
|
||
d = decompte_astreinte("2026-07-17", [{"debut": "23:00", "fin": "02:00"}])
|
||
assert d["tranches"] == 12 # 180 min
|
||
assert d["total_min"] == 180
|
||
assert [c["nom"] for c in d["categories"]] == ["Nuit"]
|
||
assert d["total_pondere_min"] == 270 # 180 × 1,5
|
||
|
||
|
||
def test_decompte_chevauchement_dedouble():
|
||
"""10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent
|
||
qu'une fois dans le total du jour, chaque event garde son propre détail."""
|
||
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"] == 2 # 10:10 aligné sur la tranche 10:00
|
||
assert d["events"][1]["min"] == 30
|
||
assert d["tranches"] == 2 # union dédoublonnée
|
||
assert d["total_min"] == 30
|
||
assert d["categories"][0]["nom"] == "Journée"
|
||
|
||
|
||
def test_decompte_event_incomplet_ignore():
|
||
"""Un événement sans debut ou sans fin est ignoré."""
|
||
d = decompte_astreinte("2026-07-13", [{"debut": "22:00"}, {"fin": "23:00"}, {}])
|
||
assert d["tranches"] == 0
|
||
assert d["total_min"] == 0
|
||
assert d["events"] == []
|
||
assert d["categories"] == []
|
||
|
||
|
||
def test_decompte_regles_personnalisees():
|
||
"""Une config custom remplace les règles par défaut (fenêtre à cheval sur minuit)."""
|
||
facteurs = [{"nom": "Garde", "jours": ["semaine"], "debut": "18:00", "fin": "09:00", "facteur": 2}]
|
||
d = decompte_astreinte("2026-07-13", [{"debut": "20:00", "fin": "21:00"}], facteurs)
|
||
assert d["total_min"] == 60
|
||
assert d["categories"][0]["nom"] == "Garde"
|
||
assert d["total_pondere_min"] == 120
|
||
|
||
|
||
# ── Nominaux ──────────────────────────────────────────────────────────────────
|
||
|
||
def test_nominaux_exclusion_totale_du_matin():
|
||
m, a = nominaux_du_jour(set(), 360, 180, 0)
|
||
assert (m, a) == (0, 180)
|
||
|
||
|
||
def test_nominaux_sans_exclusion_reste_la_moitié():
|
||
m, a = nominaux_du_jour(set(), 468, 0, 0)
|
||
assert (m, a) == (234, 234)
|
||
|
||
|
||
# ── compute_week ──────────────────────────────────────────────────────────────
|
||
|
||
def test_compute_week_decompte_absent_des_heures_de_semaine(freeze_today):
|
||
"""Lundi et jeudi complets, mardi astreinte avec event 23:00→01:00, mercredi
|
||
vide : les heures d'astreinte n'entrent jamais dans total_travaille/total_du,
|
||
seulement dans result["astreinte"]."""
|
||
pointages = [
|
||
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
|
||
_pointage("2026-07-14", astreinte_events=[{"debut": "23:00", "fin": "01:00"}]),
|
||
_pointage("2026-07-15"),
|
||
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
|
||
_pointage("2026-07-17"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
|
||
|
||
# seul le pointé compte : 2 × 468 = 15h36, astreinte exclue
|
||
assert result["total_travaille"] == "15h36"
|
||
|
||
ast = result["astreinte"]
|
||
assert ast["actif"] is True
|
||
assert ast["total_min"] == 120 # 23:00→01:00 = 8 tranches
|
||
assert ast["total_h"] == "2h00"
|
||
assert ast["total_pondere_min"] == 180 # Nuit ×1,5
|
||
assert ast["total_pondere_h"] == "3h00"
|
||
assert ast["categories"][0]["nom"] == "Nuit"
|
||
assert any(j["date"] == "2026-07-14" for j in ast["jours"])
|
||
|
||
mardi = result["jours"][1]
|
||
assert mardi["astreinte_min"] == 120
|
||
assert mardi["astreinte_h"] == "2h00"
|
||
assert mardi["astreinte_pondere_min"] == 180
|
||
|
||
|
||
def test_compute_week_fin_nocturne_decale_le_decompte_et_les_cibles(freeze_today):
|
||
"""Mardi astreinte, event 01:00→02:00 saisi sur le mercredi (lendemain) :
|
||
retour mercredi 13:00, présence du matin retirée de la semaine, aucune cible
|
||
matin, après-midi 13:30→18:18 (nominal 288 = 234 + report)."""
|
||
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", astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
|
||
_pointage("2026-07-16"),
|
||
_pointage("2026-07-17"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
|
||
mercredi = result["jours"][2]
|
||
|
||
assert mercredi["du_min"] == 468 - 180 # matin 09:00→12:00 exclu par le retour 13:00
|
||
assert mercredi["retour"] == "13:00" # 02:00 + 11h
|
||
assert mercredi["cible_matin"] is None
|
||
assert mercredi["cible_aprem"] == "18:18"
|
||
assert result["total_du"] == "36h00" # 39h - 3h de présence retirées
|
||
|
||
|
||
def test_compute_week_nuit_finissant_au_matin(freeze_today):
|
||
"""Event 23:00→06:00 (à cheval sur minuit) saisi sur le mardi astreinte : le
|
||
retour 17:00 s'applique au mercredi (matin et après-midi exclus jusqu'à 17: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",
|
||
astreinte_events=[{"debut": "23:00", "fin": "06:00"}]),
|
||
_pointage("2026-07-15"),
|
||
_pointage("2026-07-16"),
|
||
_pointage("2026-07-17"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
|
||
mercredi = result["jours"][2]
|
||
|
||
assert mercredi["retour"] == "17:00"
|
||
# matin (180) + 14:00→17:00 (180) exclus → dû = 108
|
||
assert mercredi["du_min"] == 468 - 360
|
||
assert mercredi["cible_matin"] is None
|
||
# aprem démarre à 17:00, nominal 108 (54 matin + 54 aprem) → 18:48
|
||
assert mercredi["cible_aprem_entree"] == "17:00"
|
||
assert mercredi["cible_aprem"] == "18:48"
|
||
# décompte : 23:00→06:00 = 28 tranches = 7h en Nuit ×1,5
|
||
assert result["jours"][1]["astreinte_min"] == 420
|
||
assert result["astreinte"]["total_pondere_min"] == 630
|
||
|
||
|
||
def test_compute_week_event_ignore_hors_jour_qualifie(freeze_today):
|
||
"""Un event saisi un jour non qualifié (pas d'astreinte) ne change rien au
|
||
décompte ni au décompte d'astreinte."""
|
||
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",
|
||
astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
|
||
_pointage("2026-07-15"),
|
||
_pointage("2026-07-16"),
|
||
_pointage("2026-07-17"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, [])
|
||
mardi = result["jours"][1]
|
||
assert mardi["du_min"] == HEURES_JOUR_MIN
|
||
assert mardi["retour_min"] is None
|
||
assert mardi["astreinte_min"] == 0
|
||
assert result["astreinte"]["actif"] is False
|
||
|
||
|
||
def test_compute_week_arrivee_visee_ne_precede_jamais_le_retour(freeze_today):
|
||
"""Event mardi 21:00→23:30 → retour mercredi 10:30 : arrivée cible du matin
|
||
10:30, sortie 12:00, et le nominal qui ne tient pas (234-90=144 → 54
|
||
manquantes) bascule sur l'après-midi du même jour."""
|
||
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",
|
||
astreinte_events=[{"debut": "21:00", "fin": "23:30"}]),
|
||
_pointage("2026-07-15"),
|
||
_pointage("2026-07-16"),
|
||
_pointage("2026-07-17"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
|
||
mercredi = result["jours"][2]
|
||
|
||
assert mercredi["retour"] == "10:30"
|
||
assert mercredi["cible_matin_entree"] == "10:30"
|
||
assert mercredi["cible_matin"] == "12:00"
|
||
# aprem : nominal 234 + 54 reportées = 288 depuis 13:30 → 18:18
|
||
assert mercredi["cible_aprem"] == "18:18"
|
||
|
||
|
||
def test_compute_week_demi_journee_entierement_en_repos_compte_complete(freeze_today):
|
||
"""Avec un quota de 6h/jour (180 par demi-journée), un retour 13:00 (event
|
||
01:00→02:00 sur le mercredi, mardi astreinte) couvre toute la plage du matin :
|
||
plus rien à y remplir, la demi-journée est complète d'office et l'absence de
|
||
pointage matin ne casse pas le cumul."""
|
||
heures_jour = 360
|
||
pointages = [
|
||
_pointage("2026-07-13", "09:00", "12:00", "13:30", "16:30"),
|
||
_pointage("2026-07-14", "09:00", "12:00", "13:30", "16:30"),
|
||
_pointage("2026-07-15", aprem_entree="13:30", aprem_sortie="16:30",
|
||
astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
|
||
_pointage("2026-07-16", "09:00", "12:00", "13:30", "16:30"),
|
||
_pointage("2026-07-17", "09:00", "12:00", "13:30", "16:30"),
|
||
]
|
||
result = compute_week(pointages, [], heures_jour, PLAGES, ["2026-07-14"])
|
||
mercredi = result["jours"][2]
|
||
|
||
assert mercredi["matin_nominal_min"] == 0
|
||
assert mercredi["is_complete"] is True
|
||
assert mercredi["cumul_visible"] is True
|
||
assert mercredi["du_min"] == 180 # 6h dues - 3h de présence matin retirées
|
||
|
||
|
||
# ── Stockage des astreintes ───────────────────────────────────────────────────
|
||
|
||
def test_toggle_astreinte_persiste():
|
||
models.toggle_astreinte("2026-07-14", "u-astreinte")
|
||
assert models.load_astreintes("u-astreinte") == ["2026-07-14"]
|
||
models.toggle_astreinte("2026-07-14", "u-astreinte")
|
||
assert models.load_astreintes("u-astreinte") == []
|
||
|
||
|
||
def test_load_facteurs_astreinte_defaut_si_absent():
|
||
facteurs = models.load_facteurs_astreinte()
|
||
assert any(f["nom"] == "Nuit" for f in facteurs)
|