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

@ -205,11 +205,11 @@ pointer si besoin — les exports `.ods` suivent.
L'app en déduit l'heure de retour au bureau : **fin du dernier événement + 11h de L'app en déduit l'heure de retour au bureau : **fin du dernier événement + 11h de
repos consécutif** (exigées par période de 24h), affichée sous la liste (« retour repos consécutif** (exigées par période de 24h), affichée sous la liste (« retour
≥ 13:00 »). Tout quart d'heure entamé est travaillé : la fin est arrondie au quart ≥ 13:00 »). Comme pour le décompte, la durée réelle de l'intervention est arrondie
d'heure supérieur (fin 23:37 → comptée 23:45) ; les interventions hors fenêtre au quart d'heure supérieur (début 21:00, fin 23:37 → durée comptée 2h45) ; les
18:00→18:00 ne sont pas de l'astreinte. Fin 23:30 → retour 10:30 le lendemain ; interventions hors fenêtre 18:00→18:00 ne sont pas de l'astreinte. Fin 23:30 →
fin 02:00 → retour 13:00 le jour même. Un repos qui se termine avant l'ouverture retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le jour même. Un repos qui se
ne contraint rien. termine avant l'ouverture ne contraint rien.
À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le
décompte de la semaine : décompte de la semaine :
@ -222,12 +222,14 @@ décompte de la semaine :
- une demi-journée entièrement couverte par le repos est considérée comme remplie. - une demi-journée entièrement couverte par le repos est considérée comme remplie.
Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles
sont décomptées à part, par quarts d'heure de l'horloge (:00, :15, :30, :45) — sont décomptées à part — la **durée réelle** de chaque intervention est **arrondie
toute tranche entamée compte en totalité, et un quart partagé par deux au quart d'heure supérieur** (2, 5 ou 12 min réelles → 15 min ; 22 ou 28 → 30 min),
interventions qui se suivent ou se chevauchent n'est compté **qu'une fois** même si l'arrondi ne tombe pas juste sur un quart d'heure de l'horloge (:00, :15,
(attribué à la première, les lignes d'événements somment au total du jour) — dans :30, :45), et les minutes réelles communes à deux interventions qui se suivent ou
des compteurs pondérés selon le jour et l'heure. Sous le bandeau de la semaine se chevauchent ne sont comptées **qu'une fois** (attribuées à la première, les
s'affiche le détail par événement, les totaux par compteur et le total pondéré. lignes d'événements somment au total du jour) — dans des compteurs pondérés selon
le jour et l'heure. Sous le bandeau de la semaine s'affiche le détail par
événement, les totaux par compteur et le total pondéré.
Par défaut : **Nuit** (20:00→08:00, tous types de jours) ×1,5 ; **Journée** (semaine Par défaut : **Nuit** (20:00→08:00, tous types de jours) ×1,5 ; **Journée** (semaine
Lun-Ven, 08:00→20:00) ×1 ; **Samedi** (08:00→20:00) ×1,5 ; **Dimanche / férié** Lun-Ven, 08:00→20:00) ×1 ; **Samedi** (08:00→20:00) ×1,5 ; **Dimanche / férié**
@ -236,11 +238,11 @@ Pentecôte) sont calculés automatiquement, et s'ajoutent les jours marqués **F
Ces règles sont **éditables à la main** sur la page **Réglages** (section « Ratios Ces règles sont **éditables à la main** sur la page **Réglages** (section « Ratios
des heures d'astreinte ») : nom, types de jours, fenêtre horaire et ratio de chaque des heures d'astreinte ») : nom, types de jours, fenêtre horaire et ratio de chaque
règle, ajout et suppression de lignes. La première règle qui couvre un quart règle, ajout et suppression de lignes. La première règle qui couvre une minute
gagne — place les fenêtres spécifiques avant les générales. Elles restent aussi gagne — place les fenêtres spécifiques avant les générales. Elles restent aussi
ajustables dans `data/config.json`, clé `astreinte_facteurs` : chaque règle porte ajustables dans `data/config.json`, clé `astreinte_facteurs` : chaque règle porte
un `nom`, les `jours` concernés (`semaine`, `sam`, `dim`, `ferie`), un `debut`, un `nom`, les `jours` concernés (`semaine`, `sam`, `dim`, `ferie`), un `debut`,
une `fin` et un `facteur`. Une tranche qui ne correspond à aucune règle est rangée une `fin` et un `facteur`. Une minute qui ne correspond à aucune règle est rangée
dans « Autre » avec un facteur ×1. dans « Autre » avec un facteur ×1.
Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) : Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) :

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 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 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 # 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 = [ DEFAULT_FACTEURS_ASTREINTE = [
{"nom": "Nuit", "jours": ["semaine", "sam", "dim", "ferie"], "debut": "20:00", "fin": "08:00", "facteur": 1.5}, {"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}, {"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, def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None,
fenetre: tuple[int, int] | None = None, fenetre: tuple[int, int] | None = None,
feries_declares: list[str] | set[str] | None = None) -> dict: 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 : """Décompte des heures réalisées en astreinte pour un jour : la durée réelle
toute tranche entamée est comptée en totalité. Chaque tranche est attribuée à la de chaque intervention est arrondie au quart d'heure supérieur (2, 5 ou
première règle (type de jour × fenêtre horaire) qui la couvre, puis pondérée par 12 min réelles → 15 min ; 22 ou 28 → 30 min), même si l'arrondi ne tombe
son facteur. Les événements vivent sur la timeline réelle : fin < début = pas juste sur un quart d'heure de l'horloge (:00, :15, :30, :45). Chaque
intervention à cheval sur minuit, dont la partie après minuit compte sur le jour minute comptée est attribuée à la première règle (type de jour × fenêtre
suivant. Les tranches couvertes par plusieurs événements ne sont comptées horaire) qui la couvre, puis pondérée par son facteur. Les événements vivent
qu'une fois, attribuées au premier événement qui les entame (ordre sur la timeline réelle : fin < début = intervention à cheval sur minuit,
chronologique) : les compteurs des événements somment au total du jour. 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) `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. é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 `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 ». 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) regles = _regles_astreinte(facteurs)
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date() 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 # Fériés légaux (calculés) + fériés marqués à la main : les deux qualifient
# quarts pour les règles de type « ferie ». # les minutes pour les règles de type « ferie ».
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1) feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
for s in (feries_declares or []): for s in (feries_declares or []):
try: try:
@ -212,17 +215,16 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
except (TypeError, ValueError): except (TypeError, ValueError):
continue 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) cat_j = _categorie_jour(_EPOCH_DATE + timedelta(days=qm // 1440), feries)
minute = qm % 1440 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) 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 ; vus: set[int] = set()
# la première affectation gagne si des événements se chevauchent.
quart_regles: dict[int, dict | None] = {}
events_out = [] events_out = []
# Événements triés par début : un quart entamé par plusieurs interventions qui # Événements triés par début : une minute réelle couverte par plusieurs
# se suivent ou se chevauchent n'est compté qu'une fois, attribué à la première. # interventions qui se suivent ou se chevauchent n'est comptée qu'une fois,
# attribuée à la première.
parses = [] parses = []
for e in events: for e in events:
deb, fin = e.get("debut"), e.get("fin") 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 deb_min, duree = debut_c, fin_c - debut_c
parses.append((e, deb, fin, deb_min, duree)) parses.append((e, deb, fin, deb_min, duree))
parses.sort(key=lambda t: (t[3], t[3] + t[4])) parses.sort(key=lambda t: (t[3], t[3] + t[4]))
vus: set[int] = set()
for e, deb, fin, deb_min, duree in parses: for e, deb, fin, deb_min, duree in parses:
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
quarts = [qm for qm in range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN, nouvelles = [qm for qm in range(a0, a0 + duree) if qm not in vus]
a0 + duree, PAS_ASTREINTE_MIN) vus.update(range(a0, a0 + duree))
if qm not in vus] ev_reel = len(nouvelles)
vus.update(quarts) # Durée réelle arrondie au quart d'heure supérieur : 2, 5 ou 12 min
for qm in quarts: # réelles → 15 ; 22 ou 28 → 30 — même hors des quarts d'horloge.
quart_regles.setdefault(qm, _regle_du_quart(qm)) 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] = {} ev_cats: dict[str, dict] = {}
for qm in quarts: for qm in range(arrondi0, arrondi0 + ev_min):
regle = quart_regles.get(qm) regle = _regle_de_la_minute(qm)
nom = regle["nom"] if regle else "Autre" nom = regle["nom"] if regle else "Autre"
facteur = regle["facteur"] if regle else 1.0 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 = ev_cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "min": 0})
c["tranches"] += 1 c["min"] += 1
ev_min = len(quarts) * PAS_ASTREINTE_MIN
ev_details = [ ev_details = [
{**c, "min": c["tranches"] * PAS_ASTREINTE_MIN, {**c, "min_h": minutes_to_hhmm(c["min"]),
"min_h": minutes_to_hhmm(c["tranches"] * PAS_ASTREINTE_MIN), "pondere_min": int(c["min"] * c["facteur"] + 0.5)}
"pondere_min": int(c["tranches"] * PAS_ASTREINTE_MIN * c["facteur"] + 0.5)}
for c in ev_cats.values() for c in ev_cats.values()
] ]
ev_pondere = sum(d["pondere_min"] for d in ev_details) ev_pondere = sum(d["pondere_min"] for d in ev_details)
events_out.append({ events_out.append({
"debut": deb, "fin": fin, "desc": e.get("desc") or "", "debut": deb, "fin": fin, "desc": e.get("desc") or "",
"tranches_debut": minutes_to_time(quarts[0] % 1440) if quarts else "", "reel_min": ev_reel, "reel_h": minutes_to_hhmm(ev_reel),
"tranches_fin": minutes_to_time((quarts[-1] + PAS_ASTREINTE_MIN) % 1440) if quarts else "", "arrondi_debut": minutes_to_time(arrondi0 % 1440) if ev_min else "",
"tranches": len(quarts), "min": ev_min, "min_h": minutes_to_hhmm(ev_min), "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), "pondere_min": ev_pondere, "pondere_h": minutes_to_hhmm(ev_pondere),
"details": ev_details, "details": ev_details,
}) })
cats: dict[str, dict] = {} cats: dict[str, dict] = {}
for qm, regle in quart_regles.items(): for ev in events_out:
nom = regle["nom"] if regle else "Autre" for d in ev["details"]:
facteur = regle["facteur"] if regle else 1.0 c = cats.setdefault(d["nom"], {"nom": d["nom"], "facteur": d["facteur"], "facteur_txt": d["facteur_txt"], "min": 0})
c = cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "tranches": 0}) c["min"] += d["min"]
c["tranches"] += 1
categories = [] categories = []
for nom, c in cats.items(): for nom, c in cats.items():
cmin = c["tranches"] * PAS_ASTREINTE_MIN categories.append({**c, "min_h": minutes_to_hhmm(c["min"]),
categories.append({**c, "min": cmin, "min_h": minutes_to_hhmm(cmin), "pondere_min": int(c["min"] * c["facteur"] + 0.5),
"pondere_min": int(cmin * c["facteur"] + 0.5), "pondere_h": minutes_to_hhmm(int(c["min"] * c["facteur"] + 0.5))})
"pondere_h": minutes_to_hhmm(int(cmin * c["facteur"] + 0.5))})
ordre = [r["nom"] for r in regles] + ["Autre"] 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)) 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) total_pondere = sum(c["pondere_min"] for c in categories)
return { return {
"tranches": len(quart_regles), "tranches": total_min // PAS_ASTREINTE_MIN,
"total_min": total_min, "total_min": total_min,
"total_h": minutes_to_hhmm(total_min), "total_h": minutes_to_hhmm(total_min),
"total_pondere_min": total_pondere, "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 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) : 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é 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 n'est pas de l'astreinte et ne déclenche pas de repos. Comme pour le décompte, la durée
entamé est travaillé : la fin est arrondie au quart d'heure supérieur avant réelle de l'intervention est arrondie au quart d'heure supérieur : la fin
d'ajouter le repos. Le retour s'applique au jour où se termine le repos : comptée est le début + la durée arrondie, avant d'ajouter le repos. Le retour
fin 23:30 → retour 10:30 le lendemain ; fin 23:37 (comptée 23:45) → retour s'applique au jour où se termine le repos : fin 23:30 → retour 10:30 le
10:45 ; fin 02:00 → retour 13:00 le jour même. 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 Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour
au-delà de l'ouverture.""" au-delà de l'ouverture."""
astreintes = set(astreintes or []) astreintes = set(astreintes or [])
@ -327,17 +329,19 @@ dernière activité d'astreinte.
if deb and fin: if deb and fin:
yield hhmm_to_minutes(deb), hhmm_to_minutes(fin) yield hhmm_to_minutes(deb), hhmm_to_minutes(fin)
def _fin_comptee(fin: int) -> int: def _fin_comptee(deb: int, fin: int) -> int:
"""Fin d'activité effectivement travaillée : toute tranche de 15 min """Fin d'activité effectivement comptée (sur la timeline du jour du
entamée compte, la fin est arrondie au quart d'heure supérieur.""" début) : durée réelle arrondie au quart d'heure supérieur (2, 5 ou
return -(-fin // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN 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 # É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 # 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. # y termine une intervention nocturne et le repos s'achève aujourd'hui.
if veille in astreintes: if veille in astreintes:
for deb, fin in _fins(date_str): 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: if fin >= deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin_c + REPOS_QUOTIDIEN_MIN) 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 # É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. # aujourd'hui.
if veille in astreintes: if veille in astreintes:
for deb, fin in _fins(veille): 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: 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) cands.append(fin_c + REPOS_QUOTIDIEN_MIN - 24 * 60)
if fin < deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60: if fin < deb and fin_c - 24 * 60 + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin_c + REPOS_QUOTIDIEN_MIN) cands.append(fin_c - 24 * 60 + REPOS_QUOTIDIEN_MIN)
if not cands: if not cands:
return None return None
best = max(cands) 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 # 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) # 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) 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 ; # Décompte astreinte du jour : événements multiples, durée réelle arrondie
# un férié marqué à la main qualifie les quarts pour les règles « ferie » # 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 [] events = p.get("astreinte_events") or []
dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes, feries), feries) 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) 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_min += dec["total_min"]
as_total_pondere_min += dec["total_pondere_min"] as_total_pondere_min += dec["total_pondere_min"]
for c in dec["categories"]: 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 = as_cats.setdefault(c["nom"], {"nom": c["nom"], "facteur": c["facteur"], "min": 0, "pondere_min": 0})
fc["tranches"] += c["tranches"]
fc["min"] += c["min"] fc["min"] += c["min"]
fc["pondere_min"] += c["pondere_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, "nom": nom,
"facteur": c["facteur"], "facteur": c["facteur"],
"facteur_txt": _facteur_txt(c["facteur"]), "facteur_txt": _facteur_txt(c["facteur"]),
"tranches": c["tranches"],
"min": c["min"], "min": c["min"],
"min_h": minutes_to_hhmm(c["min"]), "min_h": minutes_to_hhmm(c["min"]),
"pondere_min": c["pondere_min"], "pondere_min": c["pondere_min"],

View File

@ -489,14 +489,14 @@ def _as_block_html(date, jour):
rows = "".join(_as_event_html(date, ev) for ev in events) or _as_event_html(date, {}) rows = "".join(_as_event_html(date, ev) for ev in events) or _as_event_html(date, {})
hint = "" hint = ""
if jour.get("retour_min") is not None: if jour.get("retour_min") is not None:
hint = (f'<span class="as-hint" title="11h de repos consécutif après la dernière activité, fin arrondie au quart d\'heure entamé">' hint = (f'<span class="as-hint" title="11h de repos consécutif après la dernière activité, durée arrondie au quart d\'heure supérieur">'
f'retour ≥ {_e(jour["retour"])}</span>') f'retour ≥ {_e(jour["retour"])}</span>')
if jour.get("forfait_min"): if jour.get("forfait_min"):
hint += (f'<span class="as-hint" title="Forfait du jour AS déclaré (réglé dans Réglages) : suivi dans le récapitulatif d&#39;astreinte, hors heures travaillées">' hint += (f'<span class="as-hint" title="Forfait du jour AS déclaré (réglé dans Réglages) : suivi dans le récapitulatif d&#39;astreinte, hors heures travaillées">'
f'forfait {_e(jour["forfait_h"])}</span>') f'forfait {_e(jour["forfait_h"])}</span>')
total = "" total = ""
if jour.get("astreinte_min"): if jour.get("astreinte_min"):
total = (f'<span class="as-total" title="Décompte par quarts d&#39;heure de l&#39;horloge entamés (:00, :15, :30, :45), chacun compté 15 min entières — compteur séparé, hors heures de semaine">' total = (f'<span class="as-total" title="Durée réelle des interventions arrondie au quart d\'heure supérieur (2, 5 ou 12 min → 15 ; 22 ou 28 → 30) — compteur séparé, hors heures de semaine">'
f'astreinte : {_e(jour["astreinte_h"])}</span>') f'astreinte : {_e(jour["astreinte_h"])}</span>')
nb = len(events) nb = len(events)
label = f'{nb} intervention' + ('s' if nb != 1 else '') label = f'{nb} intervention' + ('s' if nb != 1 else '')

View File

@ -130,9 +130,9 @@
onclick="asToggle(this)"> onclick="asToggle(this)">
<span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }} <span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
</button> </button>
{% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité, fin arrondie au quart d'heure entamé">retour ≥ {{ j.retour }}</span>{% endif %} {% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité, durée arrondie au quart d'heure supérieur">retour ≥ {{ j.retour }}</span>{% endif %}
{% if j.forfait_min %}<span class="as-hint" title="Forfait du jour AS déclaré (Réglages) : suivi dans le récapitulatif d'astreinte, hors heures travaillées">forfait {{ j.forfait_h }}</span>{% endif %} {% if j.forfait_min %}<span class="as-hint" title="Forfait du jour AS déclaré (Réglages) : suivi dans le récapitulatif d'astreinte, hors heures travaillées">forfait {{ j.forfait_h }}</span>{% endif %}
{% if j.astreinte_min %}<span class="as-total" title="Décompte par quarts d'heure de l'horloge entamés (:00, :15, :30, :45), chacun compté 15 min entières — compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_h }}</span>{% endif %} {% if j.astreinte_min %}<span class="as-total" title="Durée réelle des interventions arrondie au quart d'heure supérieur (2, 5 ou 12 min → 15 ; 22 ou 28 → 30), même sans tomber sur un quart d'heure de l'horloge — compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_h }}</span>{% endif %}
</div> </div>
<div class="as-body"> <div class="as-body">
<div class="as-grid" id="as-events-{{ j.date }}"> <div class="as-grid" id="as-events-{{ j.date }}">

View File

@ -47,10 +47,11 @@
<div class="as-decompte"> <div class="as-decompte">
<div class="as-decompte-title">Astreinte — compteur séparé, hors heures de semaine</div> <div class="as-decompte-title">Astreinte — compteur séparé, hors heures de semaine</div>
<div class="as-decompte-help"> <div class="as-decompte-help">
Décompte par <b>quarts d'heure de l'horloge</b> (:00, :15, :30, :45) : tout quart Décompte par <b>durée réelle arrondie au quart d'heure supérieur</b> : 2, 5 ou
<b>entamé</b> compte 15 min entières, et un quart partagé par deux 12 min réelles → <b>15 min</b> ; 22 ou 28 → <b>30 min</b> — même si l'arrondi ne
interventions n'est compté qu'<b>une seule fois</b>. Ex. 22:39 → 23:49 = tombe pas juste sur un quart d'heure de l'horloge (:00, :15, :30, :45). Les
1h10 réelles mais 6 quarts touchés (22:30 → 00:00) = 1h30. minutes réelles communes à deux interventions ne comptent qu'<b>une seule
fois</b>. Ex. 22:39 → 23:49 = 1h10 réelles → arrondies à 1h15.
</div> </div>
{% for j in result.astreinte.jours %} {% for j in result.astreinte.jours %}
<div class="as-jour"> <div class="as-jour">
@ -64,11 +65,11 @@
<span class="as-ev-h">{{ ev.debut }} → {{ ev.fin }}</span> <span class="as-ev-h">{{ ev.debut }} → {{ ev.fin }}</span>
{% if ev.desc %}<span class="as-ev-desc">{{ ev.desc }}</span>{% endif %} {% if ev.desc %}<span class="as-ev-desc">{{ ev.desc }}</span>{% endif %}
{% if ev.tranches %} {% if ev.tranches %}
<span class="as-ev-q" title="Quarts d'heure de l'horloge (:00, :15, :30, :45) touchés par l'intervention et pas déjà comptés, chacun compté 15 min entières">{{ ev.tranches }} quart{{ 's' if ev.tranches > 1 else '' }} ({{ ev.tranches_debut }} → {{ ev.tranches_fin }}) = {{ ev.min_h }}</span> <span class="as-ev-q" title="Durée réelle arrondie au quart d'heure supérieur (2, 5 ou 12 min → 15 ; 22 ou 28 → 30), même sans tomber sur un quart d'heure de l'horloge ; les minutes déjà comptées par une intervention précédente ne sont pas recomptées">{{ ev.reel_h }} réelles{% if ev.min > ev.reel_min %} → arrondi {{ ev.min_h }}{% endif %} ({{ ev.arrondi_debut }} → {{ ev.arrondi_fin }})</span>
{% for d in ev.details %}<span class="as-ev-cat">{{ d.nom }} ×{{ d.facteur_txt }}</span>{% if not loop.last %} + {% endif %}{% endfor %} {% for d in ev.details %}<span class="as-ev-cat">{{ d.nom }} ×{{ d.facteur_txt }}</span>{% if not loop.last %} + {% endif %}{% endfor %}
<span class="as-ev-pond">pondéré → {{ ev.pondere_h }}</span> <span class="as-ev-pond">pondéré → {{ ev.pondere_h }}</span>
{% else %} {% else %}
<span class="as-ev-q" title="Tous les quarts touchés sont déjà comptés dans une intervention précédente (même quart d'heure)">0 quart — déjà compté</span> <span class="as-ev-q" title="Toutes les minutes réelles de cette intervention sont déjà comptées dans une intervention précédente (chevauchement)">0 min — déjà compté</span>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}

View File

@ -54,11 +54,12 @@
<span class="hist-label">Ratios des heures d'astreinte</span> <span class="hist-label">Ratios des heures d'astreinte</span>
<p class="settings-p"> <p class="settings-p">
Pondération des heures d'astreinte selon le type de jour et la fenêtre horaire : Pondération des heures d'astreinte selon le type de jour et la fenêtre horaire :
le décompte se fait par quarts d'heure de l'horloge (:00, :15, :30, :45) — tout le décompte se fait par durée réelle arrondie au quart d'heure supérieur (2, 5
quart entamé compte 15 min entières (ex. 22:39 → 23:49 touche les quarts 22:30 à ou 12 min réelles → 15 min ; 22 ou 28 → 30 min — ex. 22:39 → 23:49 = 1h10
23:45, soit 6 quarts = 1h30) — puis chaque quart est multiplié par le ratio de la réelles → 1h15), même si l'arrondi ne tombe pas juste sur un quart d'heure de
première règle qui le couvre. Un quart hors règle compte ×1 (« Autre ») ; la l'horloge — puis chaque minute comptée est multipliée par le ratio de la
première règle qui couvre un quart gagne, place les fenêtres spécifiques avant première règle qui la couvre. Une minute hors règle compte ×1 (« Autre ») ; la
première règle qui couvre une minute gagne, place les fenêtres spécifiques avant
les générales. Samedi, dimanche et jours fériés comptent de 0h00 à 0h00. Règles les générales. Samedi, dimanche et jours fériés comptent de 0h00 à 0h00. Règles
globales à tous les comptes. globales à tous les comptes.
</p> </p>

View File

@ -1,4 +1,4 @@
"""Astreinte : événements, décompte par tranches de 15 min, repos de 11h et retour décalé.""" """Astreinte : événements, durée réelle arrondie au quart d'heure supérieur, repos de 11h et retour décalé."""
from calcul import ( from calcul import (
compute_week, decompte_astreinte, heures_dues, heures_travaillees, hhmm_to_minutes, compute_week, decompte_astreinte, heures_dues, heures_travaillees, hhmm_to_minutes,
jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, fenetre_astreinte_jour, jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, fenetre_astreinte_jour,
@ -117,7 +117,7 @@ def test_retour_fin_du_soir_sur_le_jour_d_astreinte():
def test_retour_fin_hors_quart_arrondie_au_quart_entame(): 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 """Event 21:00→23:37 : durée réelle 157 min arrondie à 165, fin comptée
23:45 → repos terminé 10:45 le lendemain.""" 23:45 → repos terminé 10:45 le lendemain."""
p_by_date = {"2026-07-14": _pointage("2026-07-14", p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "21:00", "fin": "23:37"}])} astreinte_events=[{"debut": "21:00", "fin": "23:37"}])}
@ -125,7 +125,8 @@ def test_retour_fin_hors_quart_arrondie_au_quart_entame():
def test_retour_fin_nocturne_hors_quart_arrondie(): def test_retour_fin_nocturne_hors_quart_arrondie():
"""Event 23:00→01:07 (à cheval sur minuit) : fin comptée 01:15 → retour 12:15.""" """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", p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "23:00", "fin": "01:07"}])} 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 assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60 + 15
@ -169,25 +170,36 @@ def test_retour_fin_tot_ne_contraint_rien():
assert retour_apres_repos("2026-07-15", 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
# ── Décompte astreinte : tranches de 15 min entamées, par règle ─────────────── # ── Décompte astreinte : durée réelle arrondie au quart supérieur, par règle ───
def test_decompte_tranche_entamee_comptee_entiere(): def test_decompte_duree_reelle_arrondie_au_quart_superieur():
"""22:07→22:20 touche les tranches alignées 22:00 et 22:15 : comptées entières.""" """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"}]) d = decompte_astreinte("2026-07-13", [{"debut": "22:07", "fin": "22:20"}])
assert d["tranches"] == 2 # tranches 22:00 + 22:15 entamées assert d["tranches"] == 1 # 15 min = 1 quart d'heure
assert d["total_min"] == 2 * PAS_ASTREINTE_MIN # 30 assert d["total_min"] == PAS_ASTREINTE_MIN
assert d["total_h"] == "0h30" assert d["total_h"] == "0h15"
assert d["categories"][0]["nom"] == "Nuit" # lundi 22:xx → Nuit ×1,5 assert d["categories"][0]["nom"] == "Nuit" # lundi 22:xx → Nuit ×1,5
assert d["total_pondere_min"] == 45 # 30 × 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(): def test_decompte_arrondi_des_bornes():
"""09:50→10:05 touche les tranches 09:45 et 10:00 : 2 tranches en Journée ×1.""" """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"}]) d = decompte_astreinte("2026-07-13", [{"debut": "09:50", "fin": "10:05"}])
assert d["tranches"] == 2 # tranches 09:45 + 10:00 assert d["tranches"] == 1
assert d["total_min"] == 30 assert d["total_min"] == 15
assert d["categories"][0]["nom"] == "Journée" # lundi en journée ×1 assert d["categories"][0]["nom"] == "Journée" # lundi en journée ×1
assert d["total_pondere_min"] == 30 assert d["total_pondere_min"] == 15
def test_decompte_evenement_a_cheval_sur_minuit(): def test_decompte_evenement_a_cheval_sur_minuit():
@ -218,36 +230,33 @@ def test_decompte_vendredi_vers_samedi_reste_en_nuit():
def test_decompte_chevauchement_dedouble(): def test_decompte_chevauchement_dedouble():
"""10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent """10:00→10:20 et 10:10→10:30 se recouvrent : les minutes réelles communes
qu'une fois, attribuées au premier événement ; les lignes somment au total.""" 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", [ d = decompte_astreinte("2026-07-13", [
{"debut": "10:00", "fin": "10:20"}, {"debut": "10:00", "fin": "10:20"},
{"debut": "10:10", "fin": "10:30"}, {"debut": "10:10", "fin": "10:30"},
]) ])
assert len(d["events"]) == 2 assert len(d["events"]) == 2
assert d["events"][0]["tranches"] == 2 # tranches 10:00 + 10:15 assert d["events"][0]["min"] == 30 # 20 min réelles → arrondies à 30
assert d["events"][0]["min"] == 30 assert d["events"][1]["min"] == 15 # 10 min nouvelles → arrondies à 15
assert d["events"][1]["tranches"] == 0 # les deux tranches déjà comptées assert d["total_min"] == 45
assert d["events"][1]["min"] == 0
assert d["tranches"] == 2 # union dédoublonnée
assert d["total_min"] == 30
assert sum(e["min"] for e in d["events"]) == d["total_min"] assert sum(e["min"] for e in d["events"]) == d["total_min"]
assert d["categories"][0]["nom"] == "Journée" assert d["categories"][0]["nom"] == "Journée"
def test_decompte_quart_partage_entre_interventions(): def test_decompte_minutes_partagees_entre_interventions():
"""Deux interventions qui se suivent et finissent/débutent dans le même quart : """Deux interventions qui se chevauchent : les minutes réelles communes ne
le quart n'est compté qu'une fois (attribué à la première), les lignes sont comptées qu'une fois (attribuées à la première) ; chaque ligne est
d'événements somment au total du jour.""" arrondie au quart supérieur et les événements somment au total du jour."""
d = decompte_astreinte("2026-07-13", [ d = decompte_astreinte("2026-07-13", [
{"debut": "22:39", "fin": "23:05"}, {"debut": "22:39", "fin": "23:05"},
{"debut": "23:02", "fin": "23:49"}, {"debut": "23:02", "fin": "23:49"},
]) ])
assert d["events"][0]["tranches"] == 3 # 22:30 + 22:45 + 23:00 assert d["events"][0]["min"] == 30 # 26 min réelles → 30
assert d["events"][1]["tranches"] == 3 # 23:15 + 23:30 + 23:45 (23:00 déjà compté) assert d["events"][1]["min"] == 45 # 44 min nouvelles → 45 (3 min communes)
assert sum(e["tranches"] for e in d["events"]) == d["tranches"] == 6 assert d["total_min"] == 75
assert d["total_min"] == 90 assert d["total_pondere_min"] == 113 # 75 × 1,5 (Nuit)
assert d["total_pondere_min"] == 135 # 6 × 15 × 1,5 (Nuit)
def test_decompte_event_incomplet_ignore(): def test_decompte_event_incomplet_ignore():