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

@ -171,11 +171,15 @@ docker compose run --rm pointeuse pytest -v
---
## Congés
## Congés & jours fériés
Trois boutons par ligne : **MA** (matin), **AM** (après-midi), **J** (journée entière).\
Ils réduisent les heures dues et recalculent les préconisations **Fin cible** pour les
demi-journées restantes.
Quatre boutons par ligne : **MA** (congé matin), **AM** (congé après-midi), **J**
(journée entière) et **F** (jour férié). Les trois premiers réduisent les heures
dues et recalculent les préconisations **Fin cible** pour les demi-journées
restantes. **F** marque un jour férié (fête locale non calculée, pont...) : heures
dues nulles, aucune présence obligatoire, ligne grisée avec badge « férié » — sans
consommer de congé. Un jour marqué **F** compte aussi comme férié pour les ratios
d'astreinte (×2 en journée par défaut) et pour la fenêtre d'astreinte ci-dessous.
---
@ -183,7 +187,9 @@ demi-journées restantes.
Le bouton **AS** marque un jour d'astreinte : la période couverte va de **18:00
ce jour-là à 18:00 le lendemain** (deux astreintes consécutives se recollent sans
trou). Ce jour et son lendemain, une sous-ligne dédiée apparaît sous la ligne du
trou) — sauf **samedi, dimanche et jour férié**, qui comptent de **0h00 à 0h00**
(journée entière ; un jour AS déclaré sam/dim/férié garde sa borne du lendemain
18:00 pour ne pas tronquer les interventions nocturnes). Ce jour et son lendemain, une sous-ligne dédiée apparaît sous la ligne du
jour, pleine largeur : un menu dépliant fermé par défaut (seuls l'heure de retour
et le total restent visibles). Déplié, il offre autant de lignes que nécessaire
(**+ intervention**) sur trois colonnes : **début**, **fin** et description
@ -215,21 +221,26 @@ décompte de la semaine :
- 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
sont décomptées à part, par pas de 15 minutes (toute tranche entamée compte en
totalité), 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é.
sont décomptées à part, par quarts d'heure de l'horloge (:00, :15, :30, :45) —
toute tranche entamée compte en totalité, et un quart partagé par deux
interventions qui se suivent ou se chevauchent n'est compté **qu'une fois**
(attribué à la première, les 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
Lun-Ven, 08:00→20:00) ×1 ; **Samedi** (08:00→20:00) ×1,5 ; **Dimanche / férié**
(08:00→20:00) ×2. Ces ratios sont affichés sur la page **Réglages**. Les jours
fériés français (fixes + Pâques, Ascension, Pentecôte)
sont calculés automatiquement.
(08:00→20:00) ×2. Les jours fériés français (fixes + Pâques, Ascension,
Pentecôte) sont calculés automatiquement, et s'ajoutent les jours marqués **F**.
Ces règles sont ajustables dans `data/config.json`, clé `astreinte_facteurs` :
chaque règle porte 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 dans « Autre » avec un facteur ×1.
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
règle, ajout et suppression de lignes. La première règle qui couvre un quart
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
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
dans « Autre » avec un facteur ×1.
Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) :
partout ailleurs ils sont ignorés. Les colonnes **Astreinte** (brut), **Astreinte

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,

View File

@ -9,7 +9,7 @@ from odf.text import P
from calcul import compute_week, minutes_to_hhmm
from models import (
load_week_pointages, load_conges, load_astreintes, list_all_weeks,
load_week_pointages, load_conges, load_astreintes, load_feries, list_all_weeks,
load_plages, heures_jour_min,
)
@ -106,6 +106,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes:
plages = load_plages(user_id)
conges = load_conges(user_id)
astreintes = load_astreintes(user_id)
feries = load_feries(user_id)
dates = _week_dates(year, week)
raw = load_week_pointages(year, week, user_id)
@ -115,7 +116,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes:
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour, plages, astreintes)
result = compute_week(pointages, conges, h_jour, plages, astreintes, None, feries)
table = Table(name=f"Semaine {week:02d}-{year}")
_size_columns(table, wide_col)
@ -140,6 +141,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes:
plages = load_plages(user_id)
conges = load_conges(user_id)
astreintes = load_astreintes(user_id)
feries = load_feries(user_id)
weeks = sorted(wk for yr, wk in list_all_weeks(user_id) if yr == year)
@ -153,7 +155,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes:
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour, plages, astreintes)
result = compute_week(pointages, conges, h_jour, plages, astreintes, None, feries)
travaille_min = sum(j["travaille_min"] for j in result["jours"])
du_min = sum(j["du_min"] for j in result["jours"])
par_semaine.append((week, result, travaille_min, du_min))

View File

@ -22,12 +22,13 @@ from mail import send_mail
from models import (
load_config, load_week_pointages, save_week_pointages, load_conges,
toggle_conge, list_all_weeks, load_astreintes, toggle_astreinte,
load_feries, toggle_ferie,
load_notif_config, save_notif_config, find_user_by_token, save_presence,
regenerate_presence_token,
load_auth, save_auth, find_user_by_reset_token,
load_plages, save_plages, heures_jour_min,
append_log, load_logs, load_or_create_secret,
load_facteurs_astreinte,
load_facteurs_astreinte, save_facteurs_astreinte,
)
from calcul import (
compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues,
@ -129,6 +130,7 @@ JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanc
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.\-]{1,64}$")
_EMAIL_RE = re.compile(r"^([a-zA-Z0-9_.\-]{1,64})@([a-zA-Z0-9.\-]{1,255})$")
_HHMM_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
_JOURS_AS_VALIDES = ("semaine", "sam", "dim", "ferie")
# Échappement HTML pour les fragments construits en f-string hors templates Jinja
# (autoescape ne s'applique qu'aux templates). Defense-in-depth contre le XSS stocké.
@ -310,7 +312,7 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id):
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id), load_facteurs_astreinte())
result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id), load_facteurs_astreinte(), load_feries(user_id))
result["heures_jour"] = minutes_to_hhmm(h_jour)
return {"result": result}
@ -321,25 +323,27 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
raw = load_week_pointages(iso.year, iso.week, user_id)
plages = load_plages(user_id)
astreintes = set(load_astreintes(user_id))
feries = set(load_feries(user_id))
p_by_date = {p["date"]: p for p in raw}
p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None})
conges_date = {c["type"] for c in conges if c["date"] == date}
jour_ferie = date in feries
# Retour au bureau après 11h de repos, calculé depuis la fin de la dernière
# activité d'astreinte (saisie sur le jour ou la veille qualifiés)
retour_min = retour_apres_repos(date, p_by_date, plages, astreintes)
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 = (hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0) \
if date in astreintes else 0
trav = heures_travaillees(p, retour_min) + forfait
du = heures_dues(date, conges, h_jour, plages, retour_min)
du = heures_dues(date, conges, h_jour, 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 = trav - du
# Décompte astreinte du jour (compteur séparé, hors heures de semaine) :
# seulement si des événements sont stockés et que le jour est qualifié.
events = p.get("astreinte_events") or []
dec = (decompte_astreinte(date, events, load_facteurs_astreinte(), fenetre_astreinte_jour(date, astreintes))
dec = (decompte_astreinte(date, events, load_facteurs_astreinte(), fenetre_astreinte_jour(date, astreintes, feries), feries)
if events and jour_astreinte_qualifie(date, astreintes) else None)
return {
**p,
@ -348,6 +352,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
"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,
@ -440,18 +445,20 @@ def _oob_time_cells(date, jour, plages):
def _btns_html(date, jour):
"""Boutons congé + astreinte d'une ligne (cible du swap HTMX)."""
"""Boutons congé + astreinte + férié d'une ligne (cible du swap HTMX)."""
cg = jour["conge"]
btn_ma_on = " on" if "matin" in cg else ""
btn_am_on = " on" if "aprem" in cg else ""
btn_j_on = " jour-on" if "jour" in cg else ""
btn_as_on = " as-on" if jour.get("astreinte") else ""
btn_f_on = " ferie-on" if jour.get("ferie") else ""
return (
f'<div id="cg-btn-{_e(date)}" hx-swap-oob="true">'
f'<div class="conge-btns">'
f'<button hx-post="/conge/{_e(date)}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>'
f'<button hx-post="/conge/{_e(date)}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>'
f'<button hx-post="/conge/{_e(date)}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
f'<button hx-post="/ferie/{_e(date)}/toggle" hx-swap="none" class="btn-cg btn-ferie{btn_f_on}" title="Jour férié (dû 0, astreinte comptée 0h00 → 0h00)">F</button>'
f'<button hx-post="/astreinte/{_e(date)}/toggle" hx-swap="none" class="btn-cg btn-as{btn_as_on}" title="Jour d&#39;astreinte">AS</button>'
f'</div></div>'
)
@ -489,7 +496,7 @@ def _as_block_html(date, jour):
f'forfait + {_e(jour["forfait_h"])}</span>')
total = ""
if jour.get("astreinte_min"):
total = (f'<span class="as-total" title="Compteur séparé, hors heures de semaine">'
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">'
f'astreinte : {_e(jour["astreinte_h"])}</span>')
nb = len(events)
label = f'{nb} intervention' + ('s' if nb != 1 else '')
@ -740,7 +747,7 @@ def semaine(request: Request, year: int, week: int):
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour, plages, load_astreintes(user_id), load_facteurs_astreinte())
result = compute_week(pointages, conges, h_jour, plages, load_astreintes(user_id), load_facteurs_astreinte(), load_feries(user_id))
prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1)
next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
@ -866,6 +873,23 @@ def toggle_astreinte_route(date: str, request: Request):
return HTMLResponse(html + _oob_week_extras(soldes_ctx["result"]) + soldes_html)
@app.post("/ferie/{date}/toggle", response_class=HTMLResponse)
def toggle_ferie_route(date: str, request: Request):
"""Marque/démarque un jour férié : dû à 0, aucune présence obligatoire, et
fenêtre d'astreinte étendue à la journée entière (0h00 → 0h00)."""
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
toggle_ferie(date, user_id)
d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar()
if request.headers.get("HX-Request"):
conges = load_conges(user_id)
return _htmx_conge_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
@app.get("/stats", response_class=HTMLResponse)
def stats_page(request: Request):
user_id = _get_user(request)
@ -956,6 +980,8 @@ def settings_page(request: Request):
"plages_error": request.query_params.get("plages_error") == "1",
"forfait_saved": request.query_params.get("forfait_saved") == "1",
"forfait_error": request.query_params.get("forfait_error") == "1",
"facteurs_saved": request.query_params.get("facteurs_saved") == "1",
"facteurs_error": request.query_params.get("facteurs_error") == "1",
})
@ -1118,6 +1144,46 @@ def settings_astreinte_forfait(request: Request, astreinte_forfait: str = Form("
return RedirectResponse("/settings?forfait_saved=1", status_code=303)
@app.post("/settings/astreinte-facteurs", response_class=HTMLResponse)
async def settings_astreinte_facteurs(request: Request):
"""Règles de pondération des heures d'astreinte, éditables à la main :
nom, types de jours, fenêtre horaire et ratio de chaque règle. Une règle au
nom vide est supprimée ; une règle invalide rejette l'enregistrement."""
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
form = await request.form()
indices = sorted({int(m.group(1)) for k in form.keys()
if (m := re.fullmatch(r"f_nom_(\d+)", k))})
regles = []
for i in indices:
nom = str(form.get(f"f_nom_{i}") or "").strip()
if not nom:
continue
debut = str(form.get(f"f_debut_{i}") or "")
fin = str(form.get(f"f_fin_{i}") or "")
facteur_txt = str(form.get(f"f_facteur_{i}") or "").strip().replace(",", ".")
jours = [j for j in form.getlist(f"f_jours_{i}") if j in _JOURS_AS_VALIDES]
try:
facteur = float(facteur_txt)
except ValueError:
facteur = 0.0
if not _HHMM_RE.match(debut) or not _HHMM_RE.match(fin) or facteur <= 0 or not jours:
logger.warning("règle astreinte rejetée (invalide): user_id=%r nom=%r debut=%r fin=%r facteur=%r jours=%r",
user_id, nom, debut, fin, facteur_txt, jours)
return RedirectResponse("/settings?facteurs_error=1", status_code=303)
regles.append({"nom": nom, "jours": jours, "debut": debut, "fin": fin, "facteur": facteur})
if not regles:
logger.warning("règles astreinte vides, rien n'est enregistré: user_id=%r", user_id)
return RedirectResponse("/settings?facteurs_error=1", status_code=303)
save_facteurs_astreinte(regles)
logger.info("règles astreinte mises à jour: user_id=%r n=%d", user_id, len(regles))
append_log(user_id, "settings", "Ratios astreinte", f"{len(regles)} règle(s)")
return RedirectResponse("/settings?facteurs_saved=1", status_code=303)
@app.post("/presence/{token}/arrivee")
def presence_arrivee(request: Request, token: str):
user_id = find_user_by_token(token)

View File

@ -181,6 +181,40 @@ def toggle_astreinte(date: str, user_id: str = ""):
save_astreintes(dates, user_id)
# ── Jours fériés (marqués à la main) ──────────────────────────────────────────
def _feries_file(user_id: str) -> Path:
return _user_root(user_id) / "feries.json"
def load_feries(user_id: str = "") -> list[str]:
"""Dates marquées comme jours fériés (triées, dédupliquées)."""
f = _feries_file(user_id)
if not f.exists():
return []
try:
dates = json.loads(f.read_text())
except json.JSONDecodeError:
return []
return sorted(set(d for d in dates if isinstance(d, str)))
def save_feries(dates: list[str], user_id: str = ""):
_ensure_user_dirs(user_id)
_feries_file(user_id).write_text(
json.dumps(sorted(set(dates)), indent=2, ensure_ascii=False)
)
def toggle_ferie(date: str, user_id: str = ""):
dates = load_feries(user_id)
if date in dates:
dates.remove(date)
else:
dates.append(date)
save_feries(dates, user_id)
def load_facteurs_astreinte() -> list[dict]:
"""Règles globales de comptage des heures d'astreinte (type de jour × fenêtre
horaire × facteur) ; défauts cohérents si absentes ou invalides dans la config."""
@ -190,6 +224,13 @@ def load_facteurs_astreinte() -> list[dict]:
return DEFAULT_FACTEURS_ASTREINTE
def save_facteurs_astreinte(facteurs: list[dict]):
"""Règles globales de comptage des heures d'astreinte (config partagée)."""
cfg = load_config()
cfg["astreinte_facteurs"] = facteurs
save_config(cfg)
# ── Auth (email + password) ────────────────────────────────────────────────────
def _auth_file(user_id: str) -> Path:

View File

@ -12,11 +12,12 @@
(j.aprem_entree and j.aprem_entree > borne_aprem) or
(j.aprem_sortie and j.aprem_sortie < plages.aprem_fin)
) %}
<tr id="row-{{ j.date }}"{% if nom in ['Samedi', 'Dimanche'] %} class="we"{% endif %}>
<tr id="row-{{ j.date }}"{% if nom in ['Samedi', 'Dimanche'] or j.ferie %} class="we"{% endif %}>
<td class="col-jour">
<span class="jour-nom">{{ nom }}</span>
<span class="jour-date">{{ j.date[8:] }}/{{ j.date[5:7] }}</span>
{% if j.ferie %}<span class="ferie-badge" title="Jour férié : dû 0, astreinte comptée 0h00 → 0h00">férié</span>{% endif %}
</td>
<td class="c-matin">
@ -106,6 +107,10 @@
hx-swap="none"
class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}"
title="Journée entière">J</button>
<button hx-post="/ferie/{{ j.date }}/toggle"
hx-swap="none"
class="btn-cg btn-ferie {% if j.ferie %}ferie-on{% endif %}"
title="Jour férié : dû 0, astreinte comptée 0h00 → 0h00">F</button>
<button hx-post="/astreinte/{{ j.date }}/toggle"
hx-swap="none"
class="btn-cg btn-as {% if j.astreinte %}as-on{% endif %}"
@ -127,7 +132,7 @@
</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.forfait_min %}<span class="as-hint" title="Forfait réglé dans Réglages, ajouté au temps travaillé">forfait + {{ j.forfait_h }}</span>{% endif %}
{% if j.astreinte_min %}<span class="as-total" title="Compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_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 %}
</div>
<div class="as-body">
<div class="as-grid" id="as-events-{{ j.date }}">

View File

@ -45,7 +45,13 @@
{% endif %}
{% if result.astreinte and result.astreinte.actif %}
<div class="as-decompte">
<div class="as-decompte-title">Astreinte — tranches de 15 min entamées, comptées entières (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">
Décompte par <b>quarts d'heure de l'horloge</b> (:00, :15, :30, :45) : tout quart
<b>entamé</b> compte 15 min entières, et un quart partagé par deux
interventions n'est compté qu'<b>une seule fois</b>. Ex. 22:39 → 23:49 =
1h10 réelles mais 6 quarts touchés (22:30 → 00:00) = 1h30.
</div>
{% for j in result.astreinte.jours %}
<div class="as-jour">
<span class="as-jour-date">{{ j.date[8:] }}/{{ j.date[5:7] }}</span>
@ -53,9 +59,13 @@
<div class="as-ev">
<span class="as-ev-h">{{ ev.debut }} → {{ ev.fin }}</span>
{% if ev.desc %}<span class="as-ev-desc">{{ ev.desc }}</span>{% endif %}
<span class="as-ev-q">{{ ev.tranches }} tr. = {{ ev.min_h }}</span>
{% 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>
{% 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>
{% 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>
{% endif %}
</div>
{% endfor %}
</div>

View File

@ -436,6 +436,20 @@
.btn-cg.jour-on { background: var(--accent-2); border-color: #955505; color: #fff; }
.btn-cg.btn-as { border-radius: 4px; }
.btn-cg.as-on { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-cg.btn-ferie { border-radius: 4px; }
.btn-cg.ferie-on { background: #7B5EA7; border-color: #7B5EA7; color: #fff; }
.ferie-badge {
display: inline-block;
font-family: var(--mono);
font-size: 9px;
letter-spacing: .08em;
text-transform: uppercase;
color: #7B5EA7;
border: 1px solid #7B5EA7;
border-radius: 3px;
padding: 0 .25rem;
margin-left: .3rem;
}
/* Astreinte : sous-ligne pleine largeur sous la ligne du jour (hors colonne
après-midi), heures d'intervention (début/fin/description) repliées dans un
@ -559,10 +573,22 @@
color: var(--muted);
margin-bottom: .35rem;
}
.as-decompte-help {
color: var(--muted);
font-size: 11px;
line-height: 1.55;
margin: -.15rem 0 .45rem;
}
.as-decompte-help b { color: var(--text); font-weight: 500; }
.as-ev { display: flex; flex-wrap: wrap; gap: .45rem; align-items: baseline; padding: .12rem 0; }
.as-ev-h { font-family: var(--mono); }
.as-ev-desc { color: var(--muted); font-style: italic; }
.as-ev-q { font-family: var(--mono); color: var(--text); }
.as-ev-q {
font-family: var(--mono);
color: var(--text);
cursor: help;
border-bottom: 1px dotted var(--rule);
}
.as-ev-cat, .as-cat { color: var(--accent); font-family: var(--mono); font-size: 11px; }
.as-ev-pond { font-family: var(--mono); color: var(--accent-2); }
.as-jour-date { font-family: var(--mono); font-weight: 500; margin-right: .4rem; }

View File

@ -54,24 +54,74 @@
<span class="hist-label">Ratios des heures d'astreinte</span>
<p class="settings-p">
Pondération des heures d'astreinte selon le type de jour et la fenêtre horaire :
le décompte se fait par tranches de 15 min (toute tranche entamée compte en
totalité) et chaque tranche est multipliée par le ratio de la première règle qui
la couvre. Une tranche hors règle compte ×1 (« Autre »). Règles globales à tous
les comptes, ajustables dans <code>data/config.json</code> (clé
<code>astreinte_facteurs</code>).
le décompte se fait par quarts d'heure de l'horloge (:00, :15, :30, :45) — tout
quart entamé compte 15 min entières (ex. 22:39 → 23:49 touche les quarts 22:30 à
23:45, soit 6 quarts = 1h30) — puis chaque quart est multiplié par le ratio de la
première règle qui le couvre. Un quart hors règle compte ×1 (« Autre ») ; la
première règle qui couvre un quart 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
globales à tous les comptes.
</p>
<div class="as-ratios">
{% for f in facteurs %}
<div class="as-ratio-row">
<span class="as-ratio-nom">{{ f.nom }}</span>
<span class="as-ratio-jours">{% for j in f.jours %}{{ {'semaine': 'Lun–Ven', 'sam': 'Samedi', 'dim': 'Dimanche', 'ferie': 'Férié'}[j] }}{% if not loop.last %} · {% endif %}{% endfor %}</span>
<span class="as-ratio-fenetre">{{ f.debut }} → {{ f.fin }}</span>
<span class="as-ratio-facteur">×{{ '%g' % f.facteur }}</span>
{% if facteurs_saved %}
<div class="settings-saved">Enregistré.</div>
{% endif %}
{% if facteurs_error %}
<div class="settings-error">Règles invalides : chaque règle doit avoir un nom, au moins un type de jour, une fenêtre HH:MM et un ratio &gt; 0 ; au moins une règle est requise (efface le nom pour supprimer une règle).</div>
{% endif %}
<form method="post" action="/settings/astreinte-facteurs" class="as-facteurs-form">
<div id="as-ratio-rows">
{% for f in facteurs %}
{% set i = loop.index0 %}
<div class="as-ratio-edit" data-idx="{{ i }}">
<input type="text" class="login-input as-f-nom" name="f_nom_{{ i }}" value="{{ f.nom }}" maxlength="40" placeholder="Nom de la règle">
<div class="as-f-jours">
{% for j, lbl in [('semaine', 'Lun–Ven'), ('sam', 'Sam'), ('dim', 'Dim'), ('ferie', 'Férié')] %}
<label class="as-f-jour" title="Types de jours couverts par la règle"><input type="checkbox" name="f_jours_{{ i }}" value="{{ j }}" {% if j in f.jours %}checked{% endif %}> {{ lbl }}</label>
{% endfor %}
</div>
<input class="login-input time-range-input as-f-h" type="time" name="f_debut_{{ i }}" value="{{ f.debut }}" aria-label="Début de fenêtre">
<span class="settings-range-sep">→</span>
<input class="login-input time-range-input as-f-h" type="time" name="f_fin_{{ i }}" value="{{ f.fin }}" aria-label="Fin de fenêtre (avant début = à cheval sur minuit)">
<label class="as-f-x">×<input class="login-input as-f-facteur" type="number" name="f_facteur_{{ i }}" value="{{ '%g' % f.facteur }}" step="0.05" min="0.05" aria-label="Ratio"></label>
<button type="button" class="as-f-del" title="Supprimer la règle" onclick="asDelRatio(this)">×</button>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<div class="as-ratio-actions">
<button type="button" class="as-f-add" onclick="asAddRatio()">+ règle</button>
<button class="login-btn" type="submit">Enregistrer les ratios</button>
</div>
</form>
</div>
<script>
function asAddRatio() {
const rows = document.getElementById('as-ratio-rows');
const idx = Math.max(-1, ...[...rows.querySelectorAll('.as-ratio-edit')].map(r => +r.dataset.idx)) + 1;
const div = document.createElement('div');
div.className = 'as-ratio-edit';
div.dataset.idx = idx;
div.innerHTML =
'<input type="text" class="login-input as-f-nom" name="f_nom_' + idx + '" maxlength="40" placeholder="Nom de la règle">' +
'<div class="as-f-jours">' +
'<label class="as-f-jour"><input type="checkbox" name="f_jours_' + idx + '" value="semaine" checked> Lun–Ven</label>' +
'<label class="as-f-jour"><input type="checkbox" name="f_jours_' + idx + '" value="sam"> Sam</label>' +
'<label class="as-f-jour"><input type="checkbox" name="f_jours_' + idx + '" value="dim"> Dim</label>' +
'<label class="as-f-jour"><input type="checkbox" name="f_jours_' + idx + '" value="ferie"> Férié</label>' +
'</div>' +
'<input class="login-input time-range-input as-f-h" type="time" name="f_debut_' + idx + '" value="20:00">' +
'<span class="settings-range-sep">→</span>' +
'<input class="login-input time-range-input as-f-h" type="time" name="f_fin_' + idx + '" value="08:00">' +
'<label class="as-f-x">×<input class="login-input as-f-facteur" type="number" name="f_facteur_' + idx + '" value="1.5" step="0.05" min="0.05"></label>' +
'<button type="button" class="as-f-del" title="Supprimer la règle" onclick="asDelRatio(this)">×</button>';
rows.appendChild(div);
div.querySelector('.as-f-nom').focus();
}
function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); }
</script>
<div class="settings-card">
<span class="hist-label">Forfait par jour d'astreinte</span>
<p class="settings-p">
@ -226,22 +276,47 @@
border: 1px solid var(--rule-l);
padding: 0 .25rem;
}
.as-ratios { border: 1px solid var(--rule-l); display: flex; flex-direction: column; }
.as-ratio-row {
display: grid;
grid-template-columns: minmax(110px, auto) 1fr auto auto;
gap: .5rem 1rem;
align-items: baseline;
padding: .45rem .8rem;
.as-facteurs-form { display: flex; flex-direction: column; gap: .5rem; }
.as-ratio-edit {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem .75rem;
padding: .5rem .8rem;
border: 1px solid var(--rule-l);
}
.as-ratio-row + .as-ratio-row { border-top: 1px solid var(--rule-l); }
.as-ratio-nom { font-size: 13px; font-weight: 500; }
.as-ratio-jours { font-size: 12px; color: var(--muted); }
.as-ratio-fenetre { font-family: var(--mono); font-size: 12px; color: var(--muted); }
.as-ratio-facteur { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--accent-2); }
.as-ratio-edit + .as-ratio-edit { margin-top: .4rem; }
.as-ratio-edit .as-f-nom { width: 140px; flex-shrink: 0; }
.as-f-jours { display: flex; gap: .75rem; flex-wrap: wrap; flex: 1; }
.as-f-jour { font-size: 12px; color: var(--text); display: inline-flex; align-items: center; gap: .3rem; white-space: nowrap; }
.as-ratio-edit .as-f-h { width: 90px; flex-shrink: 0; }
.as-f-x { font-family: var(--mono); color: var(--muted); display: inline-flex; align-items: center; gap: .3rem; }
.as-ratio-edit .as-f-facteur { width: 70px; }
.as-f-del {
border: 1px solid var(--rule);
background: var(--surface);
color: var(--muted);
border-radius: 4px;
width: 22px;
height: 22px;
cursor: pointer;
line-height: 1;
flex-shrink: 0;
}
.as-f-del:hover { color: var(--neg); border-color: var(--neg); }
.as-ratio-actions { display: flex; gap: .75rem; align-items: center; margin-top: .3rem; }
.as-f-add {
border: 1px dashed var(--rule);
background: none;
color: var(--muted);
border-radius: 4px;
padding: .35rem .7rem;
cursor: pointer;
font-size: 12px;
}
.as-f-add:hover { color: var(--accent); border-color: var(--accent); }
@media (max-width: 600px) {
.as-ratio-row { grid-template-columns: 1fr auto; row-gap: .1rem; }
.as-ratio-jours, .as-ratio-fenetre { grid-column: 1 / -1; }
.as-f-jours { order: 3; width: 100%; }
}
.settings-form { display: flex; flex-direction: column; gap: .5rem; max-width: 420px; }
.settings-saved {

View File

@ -219,7 +219,7 @@ def test_decompte_vendredi_vers_samedi_reste_en_nuit():
def test_decompte_chevauchement_dedouble():
"""10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent
qu'une fois dans le total du jour, chaque event garde son propre détail."""
qu'une fois, attribuées au premier événement ; les lignes somment au total."""
d = decompte_astreinte("2026-07-13", [
{"debut": "10:00", "fin": "10:20"},
{"debut": "10:10", "fin": "10:30"},
@ -227,13 +227,29 @@ def test_decompte_chevauchement_dedouble():
assert len(d["events"]) == 2
assert d["events"][0]["tranches"] == 2 # tranches 10:00 + 10:15
assert d["events"][0]["min"] == 30
assert d["events"][1]["tranches"] == 2 # 10:10 aligné sur la tranche 10:00
assert d["events"][1]["min"] == 30
assert d["events"][1]["tranches"] == 0 # les deux tranches déjà comptées
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 d["categories"][0]["nom"] == "Journée"
def test_decompte_quart_partage_entre_interventions():
"""Deux interventions qui se suivent et finissent/débutent dans le même quart :
le quart n'est compté qu'une fois (attribué à la première), les lignes
d'événements somment au total du jour."""
d = decompte_astreinte("2026-07-13", [
{"debut": "22:39", "fin": "23:05"},
{"debut": "23:02", "fin": "23:49"},
])
assert d["events"][0]["tranches"] == 3 # 22:30 + 22:45 + 23:00
assert d["events"][1]["tranches"] == 3 # 23:15 + 23:30 + 23:45 (23:00 déjà compté)
assert sum(e["tranches"] for e in d["events"]) == d["tranches"] == 6
assert d["total_min"] == 90
assert d["total_pondere_min"] == 135 # 6 × 15 × 1,5 (Nuit)
def test_decompte_event_incomplet_ignore():
"""Un événement sans debut ou sans fin est ignoré."""
d = decompte_astreinte("2026-07-13", [{"debut": "22:00"}, {"fin": "23:00"}, {}])
@ -426,11 +442,21 @@ def test_load_facteurs_astreinte_defaut_si_absent():
def test_fenetre_astreinte_jour():
"""Le jour déclaré couvre 18:00 → 18:00 le lendemain ; le lendemain n'est
couvert que jusqu'à 18:00 ; deux astreintes consécutives se recollent."""
assert fenetre_astreinte_jour("2026-07-14", ["2026-07-14"]) == (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14"]) == (0, ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14", "2026-07-15"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-14"]) is None
couvert que jusqu'à 18:00 ; deux astreintes consécutives se recollent.
Samedi, dimanche et férié : journée entière 0h00 → 0h00."""
# jours ouvrés non fériés (mercredi 15 → vendredi 17 juillet 2026)
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-15"]) == (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-15"]) == (0, ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-15", "2026-07-16"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-17", ["2026-07-15"]) is None
# samedi et dimanche : journée entière dès 0h00, déclarés ou lendemain
# d'astreinte (le jour AS déclaré garde sa borne du lendemain 18:00)
assert fenetre_astreinte_jour("2026-07-18", ["2026-07-18"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-19", ["2026-07-18"]) == (0, 24 * 60)
# férié légal (14 juillet) : journée entière même en milieu de semaine
assert fenetre_astreinte_jour("2026-07-14", ["2026-07-13"]) == (0, 24 * 60)
# férié marqué à la main : idem
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14"], {"2026-07-15"}) == (0, 24 * 60)
def test_decompte_hors_fenetre_18h_18h_ignore():
@ -454,21 +480,58 @@ def test_decompte_hors_fenetre_18h_18h_ignore():
def test_compute_week_intervention_avant_18h_hors_astreinte(freeze_today):
"""Mardi AS : l'event 16:00→17:00 (avant 18:00) ne compte pas ; le même
créneau à 19:00→20:00 (dans la fenêtre) compte 1h (Journée ×1)."""
"""Mercredi ouvré AS : l'event 16:00→17:00 (avant 18:00) ne compte pas ; le
même créneau à 19:00→20:00 (dans la fenêtre) compte 1h (Journée ×1)."""
pointages = [
_pointage("2026-07-13"), _pointage("2026-07-16"), _pointage("2026-07-17"),
_pointage("2026-07-14", astreinte_events=[{"debut": "16:00", "fin": "17:00"}]),
_pointage("2026-07-15"),
_pointage("2026-07-13"), _pointage("2026-07-14"), _pointage("2026-07-16"),
_pointage("2026-07-17"),
_pointage("2026-07-15", astreinte_events=[{"debut": "16:00", "fin": "17:00"}]),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-15"])
assert result["astreinte"]["total_min"] == 0
assert result["jours"][1]["astreinte_min"] == 0
pointages[3] = _pointage("2026-07-14", astreinte_events=[{"debut": "19:00", "fin": "20:00"}])
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
assert result["jours"][2]["astreinte_min"] == 0
# le même créneau le soir, dans la fenêtre 18:00 → 18:00, compte (Journée ×1)
pointages[4] = _pointage("2026-07-15", astreinte_events=[{"debut": "19:00", "fin": "20:00"}])
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-15"])
assert result["astreinte"]["total_min"] == 60
assert result["astreinte"]["total_pondere_min"] == 120 # 14 juillet : férié ×2
assert result["astreinte"]["total_pondere_min"] == 60
def test_compute_week_astreinte_weekend_journee_entiere(freeze_today):
"""Vendredi AS : le samedi compte de 0h00 à 0h00 — l'intervention 20:00→21:00
du samedi (après la borne 18:00 des jours ouvrés) est de l'astreinte (Nuit ×1,5)."""
pointages = [
_pointage("2026-07-13"), _pointage("2026-07-14"), _pointage("2026-07-15"),
_pointage("2026-07-16"),
_pointage("2026-07-17", astreinte_events=[{"debut": "20:00", "fin": "21:00"}]),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-17"])
# l'événement saisi sur le vendredi est dans sa fenêtre 18:00 → 18:00
vendredi = result["jours"][4]
assert vendredi["astreinte_min"] == 60
def test_decompte_fenetre_samedi_soir_apres_18h():
"""Samedi lendemain d'astreinte : 20:00→21:00 compte (journée entière),
4 tranches en Nuit ×1,5 = 1h30 pondérées."""
fen = fenetre_astreinte_jour("2026-07-18", ["2026-07-17"])
d = decompte_astreinte("2026-07-18", [{"debut": "20:00", "fin": "21:00"}], None, fen)
assert d["total_min"] == 60
assert d["categories"][0]["nom"] == "Nuit"
assert d["total_pondere_min"] == 90
def test_compute_week_ferie_journee_entiere_ratio_x2(freeze_today):
"""Lundi AS, intervention le mardi 14 juillet (férié légal) : journée entière
0h00 → 0h00, ratio Dimanche / férié ×2 (10:00→12:00 = 2h → 4h pondérées)."""
pointages = [
_pointage("2026-07-13"),
_pointage("2026-07-14", astreinte_events=[{"debut": "10:00", "fin": "12:00"}]),
_pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-13"])
assert result["astreinte"]["total_min"] == 120
assert result["astreinte"]["total_pondere_min"] == 240 # férié ×2
def test_compute_week_astreinte_samedi_dimanche(freeze_today):

145
app/tests/test_ferie.py Normal file
View File

@ -0,0 +1,145 @@
"""Jours fériés marqués à la main : stockage, décompte (dû 0, fenêtre d'astreinte
0h00 → 0h00) et ratios d'astreinte éditables depuis la page Réglages."""
import sys
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
import models # noqa: F401
sys.path.insert(0, ".")
import main
from fastapi.testclient import TestClient
from calcul import compute_week, heures_dues
client = TestClient(main.app)
HEURES_JOUR_MIN = 468 # 39h / 5j
def _login(uid: str) -> dict:
import bcrypt
models.save_auth(uid, email=f"{uid}@x.fr",
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
r = client.post("/login/password",
data={"email": f"{uid}@x.fr", "password": "x" * 12},
follow_redirects=False)
assert r.status_code == 302, r.text
return {"user_id": r.cookies.get("user_id")}
def _pointage(date, astreinte_events=None):
return {"date": date, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None,
"astreinte_events": astreinte_events or []}
# ── Stockage ──────────────────────────────────────────────────────────────────
def test_toggle_ferie_persiste():
models.toggle_ferie("2026-07-15", "u-ferie")
assert models.load_feries("u-ferie") == ["2026-07-15"]
models.toggle_ferie("2026-07-15", "u-ferie")
assert models.load_feries("u-ferie") == []
# ── Décompte ──────────────────────────────────────────────────────────────────
def test_heures_dues_ferie_zero():
"""Un mercredi marqué férié ne doit aucune heure, congés ou pas."""
assert heures_dues("2026-07-15", [], HEURES_JOUR_MIN, ferie=True) == 0
assert heures_dues("2026-07-15", [], HEURES_JOUR_MIN, ferie=True,
retour_min=10 * 60) == 0
def test_compute_week_ferie_du_zero_jour_complet(freeze_today):
"""Mercredi marqué F : dû 0, jour complet d'office, badge férié, heures
pointées toujours comptées (delta positif)."""
pointages = [
_pointage("2026-07-13", None), _pointage("2026-07-14", None),
_pointage("2026-07-15", None), _pointage("2026-07-16", None),
_pointage("2026-07-17", None),
]
pointages[2].update(matin_entree="09:00", matin_sortie="12:24",
aprem_entree="13:30", aprem_sortie="17:54")
result = compute_week(pointages, [], HEURES_JOUR_MIN, {}, [], None, {"2026-07-15"})
mercredi = result["jours"][2]
assert mercredi["ferie"] is True
assert mercredi["du_min"] == 0
assert mercredi["is_complete"] is True
assert mercredi["travaille_min"] == HEURES_JOUR_MIN # pointé : compte en delta
assert result["jours"][1]["ferie"] is False
assert result["jours"][3]["ferie"] is False
def test_compute_week_ferie_etend_fenetre_astreinte(freeze_today):
"""AS mardi + F mercredi : l'intervention de mercredi 20:00→21:00 (après la
borne 18:00 des jours ouvrés) compte — Nuit ×1,5 = 1h30 pondérées."""
pointages = [
_pointage("2026-07-13", None),
_pointage("2026-07-14", None),
_pointage("2026-07-15", [{"debut": "20:00", "fin": "21:00"}]),
_pointage("2026-07-16", None), _pointage("2026-07-17", None),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, {}, ["2026-07-14"], None,
{"2026-07-15"})
assert result["jours"][2]["astreinte_min"] == 60
assert result["astreinte"]["total_pondere_min"] == 90
# ── Routes ────────────────────────────────────────────────────────────────────
def test_route_toggle_ferie():
cookies = _login("u-ferie-route")
r = client.post("/ferie/2026-07-15/toggle", cookies=cookies, follow_redirects=False)
assert r.status_code == 303
assert models.load_feries("u-ferie-route") == ["2026-07-15"]
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
assert r.status_code == 200
assert "ferie-on" in r.text # bouton F actif
assert "ferie-badge" in r.text # badge « férié » sur la ligne du jour
client.post("/ferie/2026-07-15/toggle", cookies=cookies, follow_redirects=False)
assert models.load_feries("u-ferie-route") == []
def test_settings_astreinte_facteurs_editables():
"""POST de règles valides : enregistrées et rechargées ; règle au nom vide
supprimée ; ratio invalide rejette tout sans rien changer."""
cookies = _login("u-facteurs")
avant = models.load_facteurs_astreinte()
data = {
"f_nom_0": "Nuit", "f_debut_0": "20:00", "f_fin_0": "08:00", "f_facteur_0": "1.5",
"f_jours_0": ["semaine", "dim"],
"f_nom_1": "Garde", "f_debut_1": "18:00", "f_fin_1": "09:00", "f_facteur_1": "2,5",
"f_jours_1": ["sam"],
}
r = client.post("/settings/astreinte-facteurs", cookies=cookies, data=data,
follow_redirects=False)
assert r.status_code == 303
assert "facteurs_saved=1" in r.headers["location"]
facteurs = models.load_facteurs_astreinte()
assert [f["nom"] for f in facteurs] == ["Nuit", "Garde"]
assert facteurs[1]["facteur"] == 2.5 # virgule acceptée
assert facteurs[1]["jours"] == ["sam"]
# suppression par nom vidé : la règle disparaît
r = client.post("/settings/astreinte-facteurs", cookies=cookies, data={
"f_nom_0": "Nuit", "f_debut_0": "20:00", "f_fin_0": "08:00", "f_facteur_0": "1.5",
"f_jours_0": ["semaine"],
"f_nom_1": "", "f_debut_1": "18:00", "f_fin_1": "09:00", "f_facteur_1": "2",
}, follow_redirects=False)
assert r.status_code == 303
assert [f["nom"] for f in models.load_facteurs_astreinte()] == ["Nuit"]
# ratio invalide : rejet, rien n'est modifié
r = client.post("/settings/astreinte-facteurs", cookies=cookies, data={
"f_nom_0": "Bug", "f_debut_0": "20:00", "f_fin_0": "08:00",
"f_facteur_0": "0", "f_jours_0": ["semaine"],
}, follow_redirects=False)
assert r.status_code == 303
assert "facteurs_error=1" in r.headers["location"]
assert [f["nom"] for f in models.load_facteurs_astreinte()] == ["Nuit"]
# remise aux règles par défaut pour les autres tests
models.save_facteurs_astreinte(avant)