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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user