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:
137
app/calcul.py
137
app/calcul.py
@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user