Quand le repos de 11h décale le retour au bureau (ex. 11:00), les heures de
présence obligatoire couvertes (ex. 09:00→11:00) sont créditées à la colonne
Travaillé au lieu d'être retirées des heures dues, qui restent complètes.
Le solde est inchangé, mais le calcul est explicite : badge « +2h00 repos »
sous le total (plages couvertes au survol) et rappel dans le bloc astreinte.
Rien n'est crédité sans heures dues (week-end, férié, congé), et le crédit
ne dépasse pas le nominal du jour.
Corrige aussi la perte des interventions postées sans description (le zip
s'arrêtait sur la liste vide des descriptions).
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
657 lines
33 KiB
Python
657 lines
33 KiB
Python
"""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,
|
||
retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN, ASTREINTE_DEBUT_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_lendemain_qualifie():
|
||
"""Event 05:00→06:00 saisi sur le lendemain de l'astreinte : le matin du
|
||
lendemain est dans la fenêtre 18:00→18:00, l'activité s'est terminée à 06:00
|
||
ce jour-là → repos jusqu'à 17:00 le même jour."""
|
||
p_by_date = {"2026-07-15": _pointage("2026-07-15",
|
||
astreinte_events=[{"debut": "05:00", "fin": "06:00"}])}
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
|
||
|
||
|
||
def test_retour_intervention_avant_18h_sur_le_jour_as_ignoree():
|
||
"""L'astreinte couvre 18:00 → 18:00 : une intervention du matin du jour
|
||
déclaré (avant 18:00) n'est pas de l'astreinte et ne déclenche pas de repos."""
|
||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||
astreinte_events=[{"debut": "10:00", "fin": "12:00"}])}
|
||
assert retour_apres_repos("2026-07-14", p_by_date, PLAGES, ["2026-07-14"]) is None
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
|
||
|
||
|
||
def test_retour_intervention_finissant_avant_18h_la_veille_ignoree():
|
||
"""Event 16:00→17:30 la veille déclarée AS : fin 17:30 arrondie 17:30, avant
|
||
le début de la fenêtre (18:00) → pas de contrainte de retour."""
|
||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||
astreinte_events=[{"debut": "16:00", "fin": "17:30"}])}
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
|
||
|
||
|
||
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_fin_hors_quart_arrondie_au_quart_entame():
|
||
"""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"}])}
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 45
|
||
|
||
|
||
def test_retour_fin_nocturne_hors_quart_arrondie():
|
||
"""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
|
||
|
||
|
||
def test_retour_toujours_sur_un_quart_dhorloge_fixe():
|
||
"""Event 22:39→23:49 : durée 70 min arrondie à 75, fin comptée 23:54,
|
||
retour brut 10:54 → arrondi au quart d'horloge fixe supérieur 11:00.
|
||
Le retour tombe toujours sur :00, :15, :30 ou :45."""
|
||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||
astreinte_events=[{"debut": "22:39", "fin": "23:49"}])}
|
||
retour = retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"])
|
||
assert retour == 11 * 60
|
||
assert retour % PAS_ASTREINTE_MIN == 0
|
||
|
||
# Début déjà hors quart : 22:41→23:52 (durée 71 → 75, fin comptée 23:56,
|
||
# retour brut 10:56) → 11:00 également.
|
||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||
astreinte_events=[{"debut": "22:41", "fin": "23:52"}])}
|
||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 11 * 60
|
||
|
||
|
||
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 : durée réelle arrondie au quart supérieur, par règle ───
|
||
|
||
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"] == 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"] == 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 = 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"] == 1
|
||
assert d["total_min"] == 15
|
||
assert d["categories"][0]["nom"] == "Journée" # lundi en journée ×1
|
||
assert d["total_pondere_min"] == 15
|
||
|
||
|
||
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 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]["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_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]["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():
|
||
"""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"] ; seules les 3h de présence obligatoire du
|
||
matin couvertes par le repos (retour 12:00) sont créditées comme effectuées."""
|
||
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"])
|
||
|
||
# pointé (2 × 7h48) + 3h de présence couvertes créditées ; astreinte exclue
|
||
assert result["total_travaille"] == "18h36"
|
||
assert result["jours"][2]["couvert_min"] == 180
|
||
|
||
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 (3h) créditée comme effectuée,
|
||
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]
|
||
|
||
# dues complètes, présence matin 09:00→12:00 couverte créditée au travaillé
|
||
assert mercredi["du_min"] == 468
|
||
assert mercredi["couvert_min"] == 180
|
||
assert mercredi["travaille_min"] == 180
|
||
assert mercredi["couvert_detail"] == "matin 09:00→12: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"] == "39h00" # dues nominales complètes
|
||
assert result["total_travaille"] == "18h36" # 2 × 7h48 + 3h de présence créditée
|
||
|
||
|
||
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) couverts → crédités 360, dues complètes 468
|
||
assert mercredi["du_min"] == 468
|
||
assert mercredi["couvert_min"] == 360
|
||
assert mercredi["couvert_h"] == "6h00"
|
||
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_presence_couvertes_comptees_effectuees(freeze_today):
|
||
"""Retour décalé à 11:00 (event mardi 22:39→23:49) : les 2h de présence
|
||
obligatoire du matin (09:00→11:00) sont comptées comme effectuées —
|
||
créditées au travaillé, dues complètes, Δ inchangé par rapport à
|
||
l'ancienne soustraction des dues."""
|
||
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": "22:39", "fin": "23:49"}]),
|
||
_pointage("2026-07-15", "11:00", "12:00", "14:00", "17: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["retour"] == "11:00"
|
||
assert mercredi["couvert_min"] == 120 # présence 09:00→11:00
|
||
assert mercredi["couvert_h"] == "2h00"
|
||
assert mercredi["couvert_detail"] == "matin 09:00→11:00"
|
||
assert mercredi["travaille_min"] == 240 + 120 # pointé (1h + 3h) + couvert
|
||
assert mercredi["travaille"] == "6h00"
|
||
assert mercredi["du_min"] == 468 # dues complètes
|
||
assert mercredi["delta_min"] == 360 - 468
|
||
|
||
|
||
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"] == 360 # dues complètes du jour (6h)
|
||
assert mercredi["couvert_min"] == 180 # 3h de présence matin couvertes, crédité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)
|
||
|
||
|
||
# ── Fenêtre d'astreinte 18:00 → 18:00 ───────────────────────────────────────
|
||
|
||
def test_fenetre_astreinte_jour():
|
||
"""Le jour déclaré couvre 18:00 → 18:00 le lendemain ; le lendemain n'est
|
||
couvert que jusqu'à 18:00 ; deux astreintes consécutives se recollent.
|
||
Samedi, dimanche et férié : journée entière 0h00 → 0h00."""
|
||
# jours ouvrés non fériés (mercredi 15 → vendredi 17 juillet 2026)
|
||
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-15"]) == (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN)
|
||
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-15"]) == (0, ASTREINTE_DEBUT_MIN)
|
||
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-15", "2026-07-16"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
|
||
assert fenetre_astreinte_jour("2026-07-17", ["2026-07-15"]) is None
|
||
# samedi et dimanche : journée entière dès 0h00, déclarés ou lendemain
|
||
# d'astreinte (le jour AS déclaré garde sa borne du lendemain 18:00)
|
||
assert fenetre_astreinte_jour("2026-07-18", ["2026-07-18"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
|
||
assert fenetre_astreinte_jour("2026-07-19", ["2026-07-18"]) == (0, 24 * 60)
|
||
# férié légal (14 juillet) : journée entière même en milieu de semaine
|
||
assert fenetre_astreinte_jour("2026-07-14", ["2026-07-13"]) == (0, 24 * 60)
|
||
# férié marqué à la main : idem
|
||
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14"], {"2026-07-15"}) == (0, 24 * 60)
|
||
|
||
|
||
def test_decompte_hors_fenetre_18h_18h_ignore():
|
||
# Matin du jour AS déclaré : avant 18:00, hors astreinte
|
||
d = decompte_astreinte("2026-07-14", [{"debut": "10:00", "fin": "12:00"}],
|
||
None, (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN))
|
||
assert d["total_min"] == 0
|
||
# Lendemain après 18:00 : hors astreinte
|
||
d = decompte_astreinte("2026-07-15", [{"debut": "19:00", "fin": "20:00"}],
|
||
None, (0, ASTREINTE_DEBUT_MIN))
|
||
assert d["total_min"] == 0
|
||
# Lendemain matin : dans la fenêtre
|
||
d = decompte_astreinte("2026-07-15", [{"debut": "01:00", "fin": "02:00"}],
|
||
None, (0, ASTREINTE_DEBUT_MIN))
|
||
assert d["total_min"] == 60
|
||
# Événement à cheval sur la borne 18:00 du lendemain : seul le fragment
|
||
# intérieur compte (17:00→18:00 = 4 tranches)
|
||
d = decompte_astreinte("2026-07-15", [{"debut": "17:00", "fin": "19:00"}],
|
||
None, (0, ASTREINTE_DEBUT_MIN))
|
||
assert d["total_min"] == 60
|
||
|
||
|
||
def test_compute_week_intervention_avant_18h_hors_astreinte(freeze_today):
|
||
"""Mercredi ouvré AS : l'event 16:00→17:00 (avant 18:00) ne compte pas ; le
|
||
même créneau à 19:00→20:00 (dans la fenêtre) compte 1h (Journée ×1)."""
|
||
pointages = [
|
||
_pointage("2026-07-13"), _pointage("2026-07-14"), _pointage("2026-07-16"),
|
||
_pointage("2026-07-17"),
|
||
_pointage("2026-07-15", astreinte_events=[{"debut": "16:00", "fin": "17:00"}]),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-15"])
|
||
assert result["astreinte"]["total_min"] == 0
|
||
assert result["jours"][2]["astreinte_min"] == 0
|
||
# le même créneau le soir, dans la fenêtre 18:00 → 18:00, compte (Journée ×1)
|
||
pointages[4] = _pointage("2026-07-15", astreinte_events=[{"debut": "19:00", "fin": "20:00"}])
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-15"])
|
||
assert result["astreinte"]["total_min"] == 60
|
||
assert result["astreinte"]["total_pondere_min"] == 60
|
||
|
||
|
||
def test_compute_week_astreinte_weekend_journee_entiere(freeze_today):
|
||
"""Vendredi AS : le samedi compte de 0h00 à 0h00 — l'intervention 20:00→21:00
|
||
du samedi (après la borne 18:00 des jours ouvrés) est de l'astreinte (Nuit ×1,5)."""
|
||
pointages = [
|
||
_pointage("2026-07-13"), _pointage("2026-07-14"), _pointage("2026-07-15"),
|
||
_pointage("2026-07-16"),
|
||
_pointage("2026-07-17", astreinte_events=[{"debut": "20:00", "fin": "21:00"}]),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-17"])
|
||
# l'événement saisi sur le vendredi est dans sa fenêtre 18:00 → 18:00
|
||
vendredi = result["jours"][4]
|
||
assert vendredi["astreinte_min"] == 60
|
||
|
||
|
||
def test_decompte_fenetre_samedi_soir_apres_18h():
|
||
"""Samedi lendemain d'astreinte : 20:00→21:00 compte (journée entière),
|
||
4 tranches en Nuit ×1,5 = 1h30 pondérées."""
|
||
fen = fenetre_astreinte_jour("2026-07-18", ["2026-07-17"])
|
||
d = decompte_astreinte("2026-07-18", [{"debut": "20:00", "fin": "21:00"}], None, fen)
|
||
assert d["total_min"] == 60
|
||
assert d["categories"][0]["nom"] == "Nuit"
|
||
assert d["total_pondere_min"] == 90
|
||
|
||
|
||
def test_compute_week_ferie_journee_entiere_ratio_x2(freeze_today):
|
||
"""Lundi AS, intervention le mardi 14 juillet (férié légal) : journée entière
|
||
0h00 → 0h00, ratio Dimanche / férié ×2 (10:00→12:00 = 2h → 4h pondérées)."""
|
||
pointages = [
|
||
_pointage("2026-07-13"),
|
||
_pointage("2026-07-14", astreinte_events=[{"debut": "10:00", "fin": "12:00"}]),
|
||
_pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-13"])
|
||
assert result["astreinte"]["total_min"] == 120
|
||
assert result["astreinte"]["total_pondere_min"] == 240 # férié ×2
|
||
|
||
|
||
def test_compute_week_astreinte_samedi_dimanche(freeze_today):
|
||
"""Samedi AS avec intervention nocturne : dû 0 (week-end), décompte en
|
||
compteur astreinte (Nuit vendredi ×1,5 puis Samedi ×1,5 sur 08:00→20: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:24", "13:30", "17:54"),
|
||
_pointage("2026-07-18", astreinte_events=[{"debut": "23:00", "fin": "09:00"}]),
|
||
_pointage("2026-07-19"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-18"])
|
||
samedi = result["jours"][5]
|
||
assert samedi["du_min"] == 0
|
||
assert samedi["astreinte_min"] == 10 * 60 # 23:00→09:00 = 10h
|
||
assert result["total_du"] == "39h00" # week-end hors dues
|
||
|
||
|
||
# ── Forfait par jour d'astreinte déclaré ───────────────────────────────────────
|
||
|
||
def test_compute_week_forfait_hors_heures_travaillees(freeze_today):
|
||
"""Forfait 1h00 réglé : attribué au jour AS déclaré uniquement (pas du
|
||
lendemain), mais jamais compté dans les heures travaillées — seulement
|
||
suivi dans le récapitulatif d'astreinte."""
|
||
plages = {**PLAGES, "astreinte_forfait": "01: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"), _pointage("2026-07-16"), _pointage("2026-07-17"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, plages, ["2026-07-14"])
|
||
mardi = result["jours"][1]
|
||
assert mardi["forfait_min"] == 60
|
||
assert mardi["travaille_min"] == HEURES_JOUR_MIN # pointé seul, forfait exclu
|
||
assert result["jours"][2]["forfait_min"] == 0 # lendemain : rien
|
||
assert result["jours"][0]["forfait_min"] == 0 # jour sans astreinte
|
||
assert result["total_travaille"] == "15h36" # 2 × 7h48, forfait exclu
|
||
# récapitulatif d'astreinte : le jour AS apparaît avec son forfait même sans
|
||
# intervention ; le forfait est rappelé mais hors compteurs pondérés
|
||
ast = result["astreinte"]
|
||
assert ast["actif"] is True
|
||
assert ast["total_min"] == 0
|
||
assert ast["total_forfait_min"] == 60
|
||
assert ast["total_forfait_h"] == "1h00"
|
||
assert [(j["date"], j["forfait_min"], j["events"]) for j in ast["jours"]] == [("2026-07-14", 60, [])]
|
||
|
||
|
||
def test_compute_week_sans_forfait_comportement_inchange(freeze_today):
|
||
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": "22:00"}]),
|
||
_pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"),
|
||
]
|
||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
|
||
assert result["jours"][1]["forfait_min"] == 0
|
||
assert result["total_travaille"] == "15h36"
|