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,
|
||||
|
||||
@ -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))
|
||||
|
||||
84
app/main.py
84
app/main.py
@ -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'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'heure de l'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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 }}">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 > 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 {
|
||||
|
||||
@ -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
145
app/tests/test_ferie.py
Normal 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)
|
||||
Reference in New Issue
Block a user