Décompte les heures d'astreinte par tranches de 15 min dans des compteurs pondérés séparés

La saisie astreinte devient une liste d'événements (début, fin, description,
nombre illimité, fin antérieure au début = intervention à cheval sur minuit).
Ces heures ne comptent jamais dans la semaine : elles sont décomptées à part
par pas de 15 minutes, toute tranche entamée comptant en totalité, et rangées
dans des compteurs selon le jour et l'heure (Nuit ×1,5, Journée semaine ×1,
Samedi ×1,5, Dimanche/férié ×2 ; jours fériés français calculés
automatiquement, règles ajustables dans config.json via astreinte_facteurs).
Le détail par événement et les totaux pondérés s'affichent sous le bandeau de
la semaine, et les exports .ods gagnent les colonnes Astreinte brute et
pondérée. Le retour au bureau (fin du dernier événement + 11h de repos) est
désormais dérivé des événements.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-08 22:26:47 +02:00
parent 065d73e15c
commit c208a17eec
11 changed files with 695 additions and 118 deletions

View File

@ -1,5 +1,5 @@
"""Business logic: compute worked hours and expected hours per day/week."""
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
DEFAULT_PLAGES = {
"matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin
@ -12,6 +12,19 @@ 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é
# 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
# couvrent, 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},
{"nom": "Samedi", "jours": ["sam"], "debut": "08:00", "fin": "20:00", "facteur": 1.5},
{"nom": "Dimanche / férié", "jours": ["dim", "ferie"], "debut": "08:00", "fin": "20:00", "facteur": 2.0},
]
_EPOCH_DATE = date(2000, 1, 1)
def hhmm_to_minutes(val: str | None) -> int:
@ -73,48 +86,192 @@ def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, ap
return max(0, m - matin_excl_min), max(0, a - aprem_excl_min)
# ── Jours fériés français (fixes + mobiles) ───────────────────────────────────
def _paques(annee: int) -> date:
"""Dimanche de Pâques (algorithme de Meeus/Jones/Butcher)."""
a = annee % 19
b, c = divmod(annee, 100)
d, e = divmod(b, 4)
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i, k = divmod(c, 4)
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
mois, jour = divmod(h + l - 7 * m + 114, 31)
return date(annee, mois, jour + 1)
def _jours_feries(annee: int) -> set[date]:
p = _paques(annee)
return {
date(annee, 1, 1), date(annee, 5, 1), date(annee, 5, 8),
date(annee, 7, 14), date(annee, 8, 15), date(annee, 11, 1),
date(annee, 11, 11), date(annee, 12, 25),
p + timedelta(days=1), # lundi de Pâques
p + timedelta(days=39), # Ascension
p + timedelta(days=50), # lundi de Pentecôte
}
def _categorie_jour(d: date, feries: set[date]) -> str:
if d in feries:
return "ferie"
return {5: "sam", 6: "dim"}.get(d.weekday(), "semaine")
def _dans_fenetre(minute: int, debut_min: int, fin_min: int) -> bool:
if debut_min <= fin_min:
return debut_min <= minute < fin_min
return minute >= debut_min or minute < fin_min # fenêtre à cheval sur minuit
def _facteur_txt(facteur: float) -> str:
return f"{facteur:.2f}".rstrip("0").rstrip(".").replace(".", ",")
def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]:
return [
{
"nom": str(r.get("nom") or "?"),
"jours": set(r.get("jours") or ()),
"debut": hhmm_to_minutes(r.get("debut")),
"fin": hhmm_to_minutes(r.get("fin")),
"facteur": float(r.get("facteur") or 1),
}
for r in (facteurs if facteurs else DEFAULT_FACTEURS_ASTREINTE)
]
def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | 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 dans les totaux du jour.
Renvoie {tranches, total_min, total_h, total_pondere_min, total_pondere_h,
events: [détail par événement], categories: [compteurs par règle]}.
"""
regles = _regles_astreinte(facteurs)
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
def _regle_du_quart(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] = {}
events_out = []
for e in events:
deb, fin = e.get("debut"), e.get("fin")
if not deb or not fin:
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
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
quarts = list(range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
a0 + duree, PAS_ASTREINTE_MIN))
for qm in quarts:
quart_regles.setdefault(qm, _regle_du_quart(qm))
ev_cats: dict[str, dict] = {}
for qm in quarts:
regle = quart_regles.get(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
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)}
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": len(quarts), "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
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))})
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_pondere = sum(c["pondere_min"] for c in categories)
return {
"tranches": len(quart_regles),
"total_min": total_min,
"total_h": minutes_to_hhmm(total_min),
"total_pondere_min": total_pondere,
"total_pondere_h": minutes_to_hhmm(total_pondere),
"events": events_out,
"categories": categories,
}
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.
Seule l'heure de FIN de cette activité est saisie, sur le jour d'astreinte ou
son lendemain. La fin peut tomber le matin (l'intervention a dépassé minuit) :
saisie sur le jour d'astreinte, une fin matinale désigne la fin de SA nuit ;
saisie sur le lendemain, une fin matinale désigne la fin de la nuit de la
veille. Le retour s'applique au jour où se termine le repos : fin 23:30 →
retour 10:30 le lendemain ; fin 06:00 → retour 17:00. Renvoie des minutes
depuis minuit, ou None si rien ne contraint le retour au-delà de l'ouverture."""
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.
Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour
au-delà de l'ouverture."""
astreintes = set(astreintes or [])
matin_debut = hhmm_to_minutes(plages["matin_debut"])
veille = _veille(date_str)
cands = []
def _fin_activite(date_op: str) -> int | None:
def _fins(date_op: str):
row = p_by_date.get(date_op) or {}
fin = row.get("astreinte_fin")
return hhmm_to_minutes(fin) if fin else None
for e in row.get("astreinte_events") or []:
deb, fin = e.get("debut"), e.get("fin")
if deb and fin:
yield hhmm_to_minutes(deb), hhmm_to_minutes(fin)
# Fin saisie sur la veille : fin de soirée → repos terminé aujourd'hui ; fin
# matinale sur un jour d'astreinte → la nuit a dépassé minuit, le repos se
# termine aussi aujourd'hui.
if jour_astreinte_qualifie(veille, astreintes):
fin = _fin_activite(veille)
if fin is not None:
if fin + REPOS_QUOTIDIEN_MIN >= 24 * 60:
cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60)
elif veille in astreintes and fin < matin_debut:
# É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):
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):
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 matinale saisie sur le jour même (lendemain d'astreinte) : la nuit de
# la veille s'est terminée après minuit.
if veille in astreintes:
fin = _fin_activite(date_str)
if fin is not None and fin < matin_debut:
cands.append(fin + REPOS_QUOTIDIEN_MIN)
if not cands:
return None
best = max(cands)
# Un repos qui se termine avant l'ouverture ne contraint rien
return best if best > matin_debut else None
return best if best > hhmm_to_minutes(plages["matin_debut"]) else None
def heures_travaillees(p: dict, retour_min: int | None = None) -> int:
@ -178,7 +335,7 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int,
return max(0, du)
def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None) -> dict:
def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None) -> dict:
"""Compute totals for a list of pointages (one week)."""
plages = {**DEFAULT_PLAGES, **(plages or {})}
astreintes = set(astreintes or [])
@ -193,12 +350,21 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
total_du = 0
jours = []
p_by_date = {p["date"]: p for p in pointages}
# Cumuls astreinte de la semaine, suivis à part : les heures astreinte ne
# comptent ni dans travaille ni dans du (compteurs séparés pondérés).
as_jours = []
as_total_min = 0
as_total_pondere_min = 0
as_cats: dict[str, dict] = {}
for p in pointages:
conges_date = {c["type"] for c in conges if c["date"] == p["date"]}
# 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
events = p.get("astreinte_events") or []
dec = decompte_astreinte(p["date"], events, facteurs) 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)
du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min)
@ -223,9 +389,37 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"aprem_nominal_min": aprem_nom,
"astreinte": p["date"] in astreintes,
"astreinte_qualifiee": jour_astreinte_qualifie(p["date"], astreintes),
"astreinte_min": dec["total_min"] if dec else 0,
"astreinte_h": dec["total_h"] if dec else "",
"astreinte_pondere_min": dec["total_pondere_min"] if dec else 0,
"retour_min": retour_min,
"retour": minutes_to_time(retour_min) if retour_min is not None else None,
})
if dec and dec["total_min"] > 0:
as_jours.append({"date": p["date"], "events": dec["events"]})
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["min"] += c["min"]
fc["pondere_min"] += c["pondere_min"]
# Catégories astreinte fusionnées sur la semaine, dans l'ordre des règles.
ordre_as = [r["nom"] for r in _regles_astreinte(facteurs)] + ["Autre"]
as_categories = []
for nom in sorted(as_cats, key=lambda n: ordre_as.index(n) if n in ordre_as else len(ordre_as)):
c = as_cats[nom]
as_categories.append({
"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"],
"pondere_h": minutes_to_hhmm(c["pondere_min"]),
})
# Mark each day as complete (all non-congé slots filled, or day is a congé/weekend).
# Une demi-journée entièrement couverte par le repos de 11h (retour après sa
@ -403,4 +597,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"slots_restants": slots_restants,
"moyenne_extra_min": (extra_total // slots_restants) if slots_restants else 0,
"moyenne_extra": minutes_to_hhmm((extra_total // slots_restants) if slots_restants else 0),
"astreinte": {
"actif": as_total_min > 0,
"total_min": as_total_min,
"total_h": minutes_to_hhmm(as_total_min),
"total_pondere_min": as_total_pondere_min,
"total_pondere_h": minutes_to_hhmm(as_total_pondere_min),
"categories": as_categories,
"jours": as_jours,
},
}