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