Astreintes : fenêtre 18h-18h, week-end affiché, forfait réglable

Un jour d'astreinte déclaré couvre désormais 18:00 → 18:00 le lendemain :
les interventions hors fenêtre ne comptent ni au décompte ni pour les
11h de repos, et deux astreintes consécutives se recollent sans trou.
La fin de la dernière intervention est arrondie au quart d'heure entamé
avant de poser le retour. Samedi et dimanche apparaissent dans la
semaine (heures dues nulles) pour pouvoir y déclarer des astreintes,
dans les exports aussi. Un réglage par utilisateur ajoute une durée
forfaitaire au temps travaillé de chaque jour d'astreinte déclaré.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-08 23:06:59 +02:00
parent dc0e786428
commit 23271d736d
8 changed files with 351 additions and 48 deletions

View File

@ -1,8 +1,8 @@
"""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,
jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, fenetre_astreinte_jour,
retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN, ASTREINTE_DEBUT_MIN,
)
import models
@ -82,12 +82,30 @@ def test_retour_fin_nocturne_sur_le_lendemain():
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",
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-14", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
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():
@ -98,6 +116,21 @@ def test_retour_fin_du_soir_sur_le_jour_d_astreinte():
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 : le quart d'heure entamé est travaillé, 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) : 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_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."""
@ -387,3 +420,103 @@ def test_toggle_astreinte_persiste():
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."""
assert fenetre_astreinte_jour("2026-07-14", ["2026-07-14"]) == (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14"]) == (0, ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14", "2026-07-15"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-14"]) is None
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):
"""Mardi 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-16"), _pointage("2026-07-17"),
_pointage("2026-07-14", astreinte_events=[{"debut": "16:00", "fin": "17:00"}]),
_pointage("2026-07-15"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
assert result["astreinte"]["total_min"] == 0
assert result["jours"][1]["astreinte_min"] == 0
pointages[3] = _pointage("2026-07-14", astreinte_events=[{"debut": "19:00", "fin": "20:00"}])
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
assert result["astreinte"]["total_min"] == 60
assert result["astreinte"]["total_pondere_min"] == 120 # 14 juillet : 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_ajoute_au_travaille_du_jour_declare(freeze_today):
"""Forfait 1h00 réglé : ajouté au travaillé du jour AS déclaré uniquement
(pas du lendemain), sans toucher aux heures dues."""
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 + 60
assert result["jours"][2]["forfait_min"] == 0 # lendemain : rien
assert result["jours"][0]["forfait_min"] == 0 # jour sans astreinte
assert result["total_travaille"] == "16h36" # 2 × 7h48 + 1h
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"