Astreinte : durée réelle arrondie au quart d'heure supérieur

Le décompte ne compte plus les quarts d'horloge (:00, :15, :30, :45)
entamés mais la durée réelle de chaque intervention arrondie au quart
d'heure supérieur (2, 5 ou 12 min → 15 ; 22 ou 28 → 30), même si
l'arrondi ne tombe pas juste sur l'horloge. Les minutes communes à deux
interventions restent comptées une seule fois, le repos de 11h s'appuie
sur la durée arrondie, et l'interface/README expliquent la nouvelle
méthode.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-09 20:12:39 +02:00
parent 5506f1ba39
commit 791b48b320
7 changed files with 142 additions and 126 deletions

View File

@ -13,12 +13,12 @@ DEFAULT_PLAGES = {
}
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é
PAS_ASTREINTE_MIN = 15 # granularité du décompte astreinte : durée réelle arrondie au quart d'heure supérieur
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
# Règles de comptage des heures d'astreinte : chaque minute comptée est
# attribuée à la première règle dont le type de jour et la fenêtre horaire la
# couvrent, puis pondérée par son facteur. "jours" parmi : semaine, sam, dim, ferie.
# couvre, puis pondérée par son facteur. "jours" parmi : semaine, sam, dim, ferie.
DEFAULT_FACTEURS_ASTREINTE = [
{"nom": "Nuit", "jours": ["semaine", "sam", "dim", "ferie"], "debut": "20:00", "fin": "08:00", "facteur": 1.5},
{"nom": "Journée", "jours": ["semaine"], "debut": "08:00", "fin": "20:00", "facteur": 1.0},
@ -183,17 +183,20 @@ def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]:
def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None,
fenetre: tuple[int, int] | None = None,
feries_declares: list[str] | set[str] | 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
son facteur. Les événements vivent sur la timeline réelle : fin < début =
intervention à cheval sur minuit, dont la partie après minuit compte sur le jour
suivant. Les tranches couvertes par plusieurs événements ne sont comptées
qu'une fois, attribuées au premier événement qui les entame (ordre
chronologique) : les compteurs des événements somment au total du jour.
"""Décompte des heures réalisées en astreinte pour un jour : la durée réelle
de chaque intervention est arrondie au quart d'heure supérieur (2, 5 ou
12 min 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 (:00, :15, :30, :45). Chaque
minute comptée est attribuée à la première règle (type de jour × fenêtre
horaire) qui la couvre, puis pondérée par son facteur. Les événements vivent
sur la timeline réelle : fin < début = intervention à cheval sur minuit,
dont la partie après minuit compte sur le jour suivant. Les minutes réelles
couvertes par plusieurs événements ne sont comptées qu'une fois, attribuées
au premier événement qui les couvre (ordre chronologique) : les compteurs
des événements somment au total 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
borne la période d'astreinte : seules les minutes à l'intérieur comptent, les
événements ou fragments hors fenêtre sont ignorés. None = tout compter.
`feries_declares` (dates « YYYY-MM-DD » marquées à la main) complète les jours
fériés légaux calculés pour l'attribution des règles de type « ferie ».
@ -203,8 +206,8 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
"""
regles = _regles_astreinte(facteurs)
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
# Fériés légaux (calculés) + fériés marqués à la main : les deux qualifient les
# quarts pour les règles de type « ferie ».
# Fériés légaux (calculés) + fériés marqués à la main : les deux qualifient
# les minutes pour les règles de type « ferie ».
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
for s in (feries_declares or []):
try:
@ -212,17 +215,16 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
except (TypeError, ValueError):
continue
def _regle_du_quart(qm: int) -> dict | None:
def _regle_de_la_minute(qm: int) -> dict | None:
cat_j = _categorie_jour(_EPOCH_DATE + timedelta(days=qm // 1440), feries)
minute = qm % 1440
return next((r for r in regles if cat_j in r["jours"] and _dans_fenetre(minute, r["debut"], r["fin"])), None)
# Attribution de chaque tranche (minute absolue alignée sur le pas) à sa règle ;
# la première affectation gagne si des événements se chevauchent.
quart_regles: dict[int, dict | None] = {}
vus: set[int] = set()
events_out = []
# Événements triés par début : un quart entamé par plusieurs interventions qui
# se suivent ou se chevauchent n'est compté qu'une fois, attribué à la première.
# Événements triés par début : une minute réelle couverte par plusieurs
# interventions qui se suivent ou se chevauchent n'est comptée qu'une fois,
# attribuée à la première.
parses = []
for e in events:
deb, fin = e.get("debut"), e.get("fin")
@ -238,58 +240,57 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
deb_min, duree = debut_c, fin_c - debut_c
parses.append((e, deb, fin, deb_min, duree))
parses.sort(key=lambda t: (t[3], t[3] + t[4]))
vus: set[int] = set()
for e, deb, fin, deb_min, duree in parses:
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
quarts = [qm for qm in range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
a0 + duree, PAS_ASTREINTE_MIN)
if qm not in vus]
vus.update(quarts)
for qm in quarts:
quart_regles.setdefault(qm, _regle_du_quart(qm))
nouvelles = [qm for qm in range(a0, a0 + duree) if qm not in vus]
vus.update(range(a0, a0 + duree))
ev_reel = len(nouvelles)
# Durée réelle arrondie au quart d'heure supérieur : 2, 5 ou 12 min
# réelles → 15 ; 22 ou 28 → 30 — même hors des quarts d'horloge.
ev_min = -(-ev_reel // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
# La durée comptée court depuis la première minute non déjà couverte ;
# chaque minute comptée (réelle ou d'arrondi) rejoint sa règle.
arrondi0 = nouvelles[0] if nouvelles else a0
ev_cats: dict[str, dict] = {}
for qm in quarts:
regle = quart_regles.get(qm)
for qm in range(arrondi0, arrondi0 + ev_min):
regle = _regle_de_la_minute(qm)
nom = regle["nom"] if regle else "Autre"
facteur = regle["facteur"] if regle else 1.0
c = ev_cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "tranches": 0})
c["tranches"] += 1
ev_min = len(quarts) * PAS_ASTREINTE_MIN
c = ev_cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "min": 0})
c["min"] += 1
ev_details = [
{**c, "min": c["tranches"] * PAS_ASTREINTE_MIN,
"min_h": minutes_to_hhmm(c["tranches"] * PAS_ASTREINTE_MIN),
"pondere_min": int(c["tranches"] * PAS_ASTREINTE_MIN * c["facteur"] + 0.5)}
{**c, "min_h": minutes_to_hhmm(c["min"]),
"pondere_min": int(c["min"] * c["facteur"] + 0.5)}
for c in ev_cats.values()
]
ev_pondere = sum(d["pondere_min"] for d in ev_details)
events_out.append({
"debut": deb, "fin": fin, "desc": e.get("desc") or "",
"tranches_debut": minutes_to_time(quarts[0] % 1440) if quarts else "",
"tranches_fin": minutes_to_time((quarts[-1] + PAS_ASTREINTE_MIN) % 1440) if quarts else "",
"tranches": len(quarts), "min": ev_min, "min_h": minutes_to_hhmm(ev_min),
"reel_min": ev_reel, "reel_h": minutes_to_hhmm(ev_reel),
"arrondi_debut": minutes_to_time(arrondi0 % 1440) if ev_min else "",
"arrondi_fin": minutes_to_time((arrondi0 + ev_min) % 1440) if ev_min else "",
"tranches": ev_min // PAS_ASTREINTE_MIN, "min": ev_min, "min_h": minutes_to_hhmm(ev_min),
"pondere_min": ev_pondere, "pondere_h": minutes_to_hhmm(ev_pondere),
"details": ev_details,
})
cats: dict[str, dict] = {}
for qm, regle in quart_regles.items():
nom = regle["nom"] if regle else "Autre"
facteur = regle["facteur"] if regle else 1.0
c = cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "tranches": 0})
c["tranches"] += 1
for ev in events_out:
for d in ev["details"]:
c = cats.setdefault(d["nom"], {"nom": d["nom"], "facteur": d["facteur"], "facteur_txt": d["facteur_txt"], "min": 0})
c["min"] += d["min"]
categories = []
for nom, c in cats.items():
cmin = c["tranches"] * PAS_ASTREINTE_MIN
categories.append({**c, "min": cmin, "min_h": minutes_to_hhmm(cmin),
"pondere_min": int(cmin * c["facteur"] + 0.5),
"pondere_h": minutes_to_hhmm(int(cmin * c["facteur"] + 0.5))})
categories.append({**c, "min_h": minutes_to_hhmm(c["min"]),
"pondere_min": int(c["min"] * c["facteur"] + 0.5),
"pondere_h": minutes_to_hhmm(int(c["min"] * c["facteur"] + 0.5))})
ordre = [r["nom"] for r in regles] + ["Autre"]
categories.sort(key=lambda c: ordre.index(c["nom"]) if c["nom"] in ordre else len(ordre))
total_min = len(quart_regles) * PAS_ASTREINTE_MIN
total_min = sum(e["min"] for e in events_out)
total_pondere = sum(c["pondere_min"] for c in categories)
return {
"tranches": len(quart_regles),
"tranches": total_min // PAS_ASTREINTE_MIN,
"total_min": total_min,
"total_h": minutes_to_hhmm(total_min),
"total_pondere_min": total_pondere,
@ -309,11 +310,12 @@ dernière activité d'astreinte.
termine l'activité. L'astreinte couvre 18:00 → 18:00 le lendemain — sauf
samedi, dimanche et jour férié, où la journée entière compte (0h00 → 0h00) :
hors ces jours, 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.
n'est pas de l'astreinte et ne déclenche pas de repos. Comme pour le décompte, la durée
réelle de l'intervention est arrondie au quart d'heure supérieur : la fin
comptée est le début + la durée arrondie, avant d'ajouter le repos. Le retour
s'applique au jour où se termine le repos : fin 23:30 → retour 10:30 le
lendemain ; 21:00 → 23:37 (durée comptée 2h45, fin 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 [])
@ -327,17 +329,19 @@ dernière activité d'astreinte.
if deb and fin:
yield hhmm_to_minutes(deb), hhmm_to_minutes(fin)
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
def _fin_comptee(deb: int, fin: int) -> int:
"""Fin d'activité effectivement comptée (sur la timeline du jour du
début) : durée réelle arrondie au quart d'heure supérieur (2, 5 ou
12 min → 15 ; 22 ou 28 → 30), même hors des quarts d'horloge."""
duree = fin - deb if fin >= deb else 24 * 60 - deb + fin
return deb + -(-duree // 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):
fin_c = _fin_comptee(fin)
fin_c = _fin_comptee(deb, 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
@ -346,11 +350,11 @@ dernière activité d'astreinte.
# aujourd'hui.
if veille in astreintes:
for deb, fin in _fins(veille):
fin_c = _fin_comptee(fin)
fin_c = _fin_comptee(deb, 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 fin < deb and fin_c - 24 * 60 + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin_c - 24 * 60 + REPOS_QUOTIDIEN_MIN)
if not cands:
return None
best = max(cands)
@ -451,8 +455,9 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
# Retour au bureau après 11h de repos, calculé depuis l'heure de fin de la
# dernière activité d'astreinte (saisie sur le jour ou la veille qualifiés)
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 ;
# un férié marqué à la main qualifie les quarts pour les règles « ferie »
# Décompte astreinte du jour : événements multiples, durée réelle arrondie
# au quart d'heure supérieur ; un férié marqué à la main qualifie les
# minutes pour les règles « ferie »
events = p.get("astreinte_events") or []
dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes, feries), feries)
if jour_astreinte_qualifie(p["date"], astreintes) and events else None)
@ -511,8 +516,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
as_total_min += dec["total_min"]
as_total_pondere_min += dec["total_pondere_min"]
for c in dec["categories"]:
fc = as_cats.setdefault(c["nom"], {"nom": c["nom"], "facteur": c["facteur"], "tranches": 0, "min": 0, "pondere_min": 0})
fc["tranches"] += c["tranches"]
fc = as_cats.setdefault(c["nom"], {"nom": c["nom"], "facteur": c["facteur"], "min": 0, "pondere_min": 0})
fc["min"] += c["min"]
fc["pondere_min"] += c["pondere_min"]
@ -525,7 +529,6 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"nom": nom,
"facteur": c["facteur"],
"facteur_txt": _facteur_txt(c["facteur"]),
"tranches": c["tranches"],
"min": c["min"],
"min_h": minutes_to_hhmm(c["min"]),
"pondere_min": c["pondere_min"],