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

@ -9,10 +9,12 @@ DEFAULT_PLAGES = {
"pause_dejeuner_fin": "13:30", # reprise par défaut projetée tant que l'entrée aprem n'est pas saisie
"arrivee_visee": "", # arrivée du matin visée pour les préconisations (vide = matin_debut)
"depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun)
"astreinte_forfait": "", # durée ajoutée au temps travaillé de chaque jour d'astreinte déclaré (HH:MM, vide = aucune)
}
REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau
PAS_ASTREINTE_MIN = 15 # pas du décompte astreinte : toute tranche entamée compte en totalité
ASTREINTE_DEBUT_MIN = 18 * 60 # un jour d'astreinte déclaré couvre 18:00 → 18:00 le lendemain
# Règles de comptage des heures d'astreinte : chaque tranche de 15 min est
# attribuée à la première règle dont le type de jour et la fenêtre horaire la
@ -56,6 +58,23 @@ def jour_astreinte_qualifie(date_str: str, astreintes) -> bool:
return date_str in astreintes or _veille(date_str) in astreintes
def fenetre_astreinte_jour(date_str: str, astreintes) -> tuple[int, int] | None:
"""Bornes (minutes sur la timeline du jour, 0 = minuit du jour, > 1440 =
lendemain) de la période d'astreinte couvrant ce jour. Un jour déclaré AS
couvre 18:00 → 18:00 le lendemain : son propre soir à partir de 18:00, et le
lendemain jusqu'à 18:00 (le matin du jour AS lui-même n'est pas de
l'astreinte). Deux astreintes consécutives se recollent sans trou ni
chevauchement. None si le jour n'est pas qualifié."""
astreintes = set(astreintes or [])
jour_as = date_str in astreintes
veille_as = _veille(date_str) in astreintes
if not (jour_as or veille_as):
return None
debut = 0 if veille_as else ASTREINTE_DEBUT_MIN
fin = 24 * 60 + ASTREINTE_DEBUT_MIN if jour_as else ASTREINTE_DEBUT_MIN
return debut, fin
def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) -> tuple[int, int]:
"""Présence obligatoire (matin, aprem) tombant avant l'heure de retour au bureau,
en minutes : c'est le temps de présence retiré du décompte de la semaine quand le
@ -144,7 +163,8 @@ def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]:
]
def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None) -> dict:
def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None,
fenetre: tuple[int, int] | None = None) -> dict:
"""Décompte des heures réalisées en astreinte pour un jour, par pas de 15 min :
toute tranche entamée est comptée en totalité. Chaque tranche est attribuée à la
première règle (type de jour × fenêtre horaire) qui la couvre, puis pondérée par
@ -153,6 +173,10 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
suivant. Les tranches couvertes par plusieurs événements ne sont comptées qu'une
fois dans les totaux du jour.
`fenetre` (bornes en minutes sur la timeline du jour, cf. fenetre_astreinte_jour)
borne la période d'astreinte : seules les tranches à l'intérieur comptent, les
événements ou fragments hors fenêtre sont ignorés. None = tout compter.
Renvoie {tranches, total_min, total_h, total_pondere_min, total_pondere_h,
events: [détail par événement], categories: [compteurs par règle]}.
"""
@ -175,6 +199,12 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
continue
deb_min, fin_min = hhmm_to_minutes(deb), hhmm_to_minutes(fin)
duree = fin_min - deb_min if fin_min >= deb_min else 24 * 60 - deb_min + fin_min
if fenetre is not None:
debut_c = max(deb_min, fenetre[0])
fin_c = min(deb_min + duree, fenetre[1])
if fin_c <= debut_c:
continue
deb_min, duree = debut_c, fin_c - debut_c
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
quarts = list(range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
a0 + duree, PAS_ASTREINTE_MIN))
@ -232,13 +262,18 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
def retour_apres_repos(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None:
"""Heure de retour au bureau après les 11h de repos consécutif qui suivent la
dernière activité d'astreinte.
dernière activité d'astreinte.
Chaque activité d'astreinte est un événement {debut, fin} saisi sur le jour de
son début (les événements remplacent l'ancienne fin unique). fin < debut
désigne une intervention à cheval sur minuit, dont la fin (heure du lendemain)
termine l'activité. Le retour s'applique au jour où se termine le repos : fin
23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le jour même.
termine l'activité. L'astreinte couvre 18:00 → 18:00 le lendemain : une
intervention terminée avant 18:00 sur le jour déclaré n'est pas de l'astreinte
et ne déclenche pas de repos. Comme pour le décompte, tout quart d'heure
entamé est travaillé : la fin est arrondie au quart d'heure supérieur avant
d'ajouter le repos. Le retour s'applique au jour où se termine le repos :
fin 23:30 → retour 10:30 le lendemain ; fin 23:37 (comptée 23:45) → retour
10:45 ; fin 02:00 → retour 13:00 le jour même.
Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour
au-delà de l'ouverture."""
astreintes = set(astreintes or [])
@ -252,21 +287,30 @@ def retour_apres_repos(date_str: str, p_by_date: dict, plages: dict, astreintes)
if deb and fin:
yield hhmm_to_minutes(deb), hhmm_to_minutes(fin)
# Événements du jour : activité terminée aujourd'hui, le repos s'achève donc
# aujourd'hui (une fin matinale est la fin d'une intervention nocturne du jour).
if jour_astreinte_qualifie(date_str, astreintes):
def _fin_comptee(fin: int) -> int:
"""Fin d'activité effectivement travaillée : toute tranche de 15 min
entamée compte, la fin est arrondie au quart d'heure supérieur."""
return -(-fin // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
# Événements du jour, fin matinale : le matin du jour n'est de l'astreinte
# que si la veille était déclarée AS (fenêtre 18:00→18:00) ; une fin matinale
# y termine une intervention nocturne et le repos s'achève aujourd'hui.
if veille in astreintes:
for deb, fin in _fins(date_str):
if fin >= deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin + REPOS_QUOTIDIEN_MIN)
# Événements de la veille : fin de soirée → le repos se termine aujourd'hui ;
# fin après minuit (fin < deb) → l'activité s'est achevée aujourd'hui à fin,
# le repos s'achève donc aujourd'hui.
if jour_astreinte_qualifie(veille, astreintes):
fin_c = _fin_comptee(fin)
if fin >= deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin_c + REPOS_QUOTIDIEN_MIN)
# Événements de la veille déclarée AS : fin de soirée (après 18:00, dans la
# fenêtre) → le repos se termine aujourd'hui ; fin après minuit (fin < deb) →
# l'activité s'est achevée aujourd'hui à fin, le repos s'achève donc
# aujourd'hui.
if veille in astreintes:
for deb, fin in _fins(veille):
if fin >= deb and fin + REPOS_QUOTIDIEN_MIN >= 24 * 60:
cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60)
if fin < deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin + REPOS_QUOTIDIEN_MIN)
fin_c = _fin_comptee(fin)
if fin >= deb and fin_c > ASTREINTE_DEBUT_MIN and fin_c + REPOS_QUOTIDIEN_MIN >= 24 * 60:
cands.append(fin_c + REPOS_QUOTIDIEN_MIN - 24 * 60)
if fin < deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin_c + REPOS_QUOTIDIEN_MIN)
if not cands:
return None
best = max(cands)
@ -339,6 +383,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"""Compute totals for a list of pointages (one week)."""
plages = {**DEFAULT_PLAGES, **(plages or {})}
astreintes = set(astreintes or [])
forfait_as_min = hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0
matin_debut_min = hhmm_to_minutes(plages["matin_debut"])
matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
@ -364,9 +409,11 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
retour_min = retour_apres_repos(p["date"], p_by_date, plages, astreintes)
# Décompte astreinte du jour : événements multiples, pas de 15 min entamées
events = p.get("astreinte_events") or []
dec = decompte_astreinte(p["date"], events, facteurs) if (jour_astreinte_qualifie(p["date"], astreintes) and events) else None
dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes))
if jour_astreinte_qualifie(p["date"], astreintes) and events else None)
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages)
travaille = heures_travaillees(p, retour_min)
forfait = forfait_as_min if p["date"] in astreintes else 0
travaille = heures_travaillees(p, retour_min) + forfait
du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min)
du_full = du + matin_ex + aprem_ex
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex)
@ -382,6 +429,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"du": minutes_to_hhmm(du),
"delta": minutes_to_hhmm(delta),
"conge": conges_date,
"forfait_min": forfait,
"forfait_h": minutes_to_hhmm(forfait) if forfait else "",
"du_full_min": du_full,
"matin_excl_min": matin_ex,
"aprem_excl_min": aprem_ex,