Ajoute les jours fériés, l'astreinte week-end 0h-0h et les ratios éditables

- Bouton F : marque un jour férié (dû 0, sans consommer de congé) ; il
  qualifie les règles « ferie » et étend la fenêtre d'astreinte du jour.
- Samedi, dimanche et fériés : l'astreinte compte de 0h00 à 0h00 ; un jour
  AS déclaré garde sa borne du lendemain 18h pour ne pas tronquer les
  interventions nocturnes.
- Ratios d'astreinte éditables à la main dans Réglages (nom, jours, fenêtre,
  ratio), avec ajout/suppression de règles et validation.
- Un quart d'heure entamé par deux interventions qui se suivent n'est compté
  qu'une fois : les lignes d'événements somment au total du jour.
- Décompte mieux expliqué dans l'interface : quarts d'horloge (:00/:15/:30/:45),
  fenêtre couverte par événement, exemple chiffré.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-08 23:30:15 +02:00
parent 23271d736d
commit dc80e81db2
11 changed files with 599 additions and 111 deletions

View File

@ -58,29 +58,44 @@ 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:
def fenetre_astreinte_jour(date_str: str, astreintes, feries=None) -> 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é."""
chevauchement. Samedi, dimanche et jour férié (légal ou marqué à la main via
`feries`, dates « YYYY-MM-DD ») : la journée compte dès 0h00 — un jour AS
déclaré garde sa borne du lendemain 18:00 (interventions nocturnes), un
sam/dim/férié lendemain d'astreinte compte jusqu'à minuit.
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
d = datetime.strptime(date_str, "%Y-%m-%d").date()
feries_eff = _jours_feries(d.year) | _jours_feries(d.year + 1)
for s in (feries or []):
try:
feries_eff.add(datetime.strptime(s, "%Y-%m-%d").date())
except (TypeError, ValueError):
continue
plein = d.weekday() >= 5 or d in feries_eff
debut = 0 if (veille_as or plein) else ASTREINTE_DEBUT_MIN
if jour_as:
fin = 24 * 60 + ASTREINTE_DEBUT_MIN
else:
fin = 24 * 60 if plein else ASTREINTE_DEBUT_MIN
return debut, fin
def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) -> tuple[int, int]:
def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict, ferie: bool = False) -> 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
repos de 11h décale l'arrivée. Les demi-journées en congé ne sont pas décomptées
deux fois."""
if retour_min is None:
deux fois. Un jour férié n'a aucune présence obligatoire."""
if retour_min is None or ferie:
return 0, 0
matin_debut = hhmm_to_minutes(plages["matin_debut"])
matin_fin = hhmm_to_minutes(plages["matin_fin"])
@ -91,10 +106,11 @@ def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) ->
return matin_ex, aprem_ex
def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int) -> tuple[int, int]:
"""Nominaux (attendus) matin et aprem en minutes, après congés et exclusion liée
à l'heure de retour."""
if "jour" in conges_date:
def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int,
ferie: bool = False) -> tuple[int, int]:
"""Nominaux (attendus) matin et aprem en minutes, après congés, jour férié et
exclusion liée à l'heure de retour."""
if ferie or "jour" in conges_date:
m = a = 0
elif "matin" in conges_date:
m, a = 0, du_full_min
@ -164,25 +180,36 @@ 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) -> dict:
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 dans les totaux du 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.
`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.
`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 ».
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()
# 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 ».
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
for s in (feries_declares or []):
try:
feries.add(datetime.strptime(s, "%Y-%m-%d").date())
except (TypeError, ValueError):
continue
def _regle_du_quart(qm: int) -> dict | None:
cat_j = _categorie_jour(_EPOCH_DATE + timedelta(days=qm // 1440), feries)
@ -193,6 +220,9 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
# la première affectation gagne si des événements se chevauchent.
quart_regles: dict[int, dict | None] = {}
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.
parses = []
for e in events:
deb, fin = e.get("debut"), e.get("fin")
if not deb or not fin:
@ -205,9 +235,15 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
if fin_c <= debut_c:
continue
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 = list(range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
a0 + duree, PAS_ASTREINTE_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))
ev_cats: dict[str, dict] = {}
@ -227,6 +263,8 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
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),
"pondere_min": ev_pondere, "pondere_h": minutes_to_hhmm(ev_pondere),
"details": ev_details,
@ -267,9 +305,10 @@ 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é. 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
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
@ -350,13 +389,14 @@ def heures_travaillees(p: dict, retour_min: int | None = None) -> int:
def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int,
plages: dict | None = None, retour_min: int | None = None) -> int:
"""Return expected minutes for a date, accounting for vacations. Si une heure de
retour est définie (repos de 11h après astreinte), la présence obligatoire avant
cette heure est retirée du décompte de la semaine."""
plages: dict | None = None, retour_min: int | None = None,
ferie: bool = False) -> int:
"""Return expected minutes for a date, accounting for vacations and public
holidays. Si une heure de retour est définie (repos de 11h après astreinte), la
présence obligatoire avant cette heure est retirée du décompte de la semaine."""
d = datetime.strptime(date_str, "%Y-%m-%d").date()
# Weekend = no hours due
if d.weekday() >= 5:
# Weekend ou jour férié = no hours due
if d.weekday() >= 5 or ferie:
return 0
conges_date = {c["type"] for c in conges if c["date"] == date_str}
@ -379,10 +419,11 @@ 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, facteurs: list[dict] | 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, feries: list[str] | set[str] | None = None) -> dict:
"""Compute totals for a list of pointages (one week)."""
plages = {**DEFAULT_PLAGES, **(plages or {})}
astreintes = set(astreintes or [])
feries = set(feries 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"])
@ -404,19 +445,21 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
for p in pointages:
conges_date = {c["type"] for c in conges if c["date"] == p["date"]}
jour_ferie = p["date"] in feries
# 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
# 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 »
events = p.get("astreinte_events") or []
dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes))
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)
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages)
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages, jour_ferie)
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 = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min, jour_ferie)
du_full = du + matin_ex + aprem_ex
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex)
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie)
delta = travaille - du
total_travaille += travaille
total_du += du
@ -429,6 +472,7 @@ 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,
"ferie": jour_ferie,
"forfait_min": forfait,
"forfait_h": minutes_to_hhmm(forfait) if forfait else "",
"du_full_min": du_full,