Astreintes : fenêtre 18h-18h, week-end affiché, forfait réglable
Un jour d'astreinte déclaré couvre désormais 18:00 → 18:00 le lendemain :
les interventions hors fenêtre ne comptent ni au décompte ni pour les
11h de repos, et deux astreintes consécutives se recollent sans trou.
La fin de la dernière intervention est arrondie au quart d'heure entamé
avant de poser le retour. Samedi et dimanche apparaissent dans la
semaine (heures dues nulles) pour pouvoir y déclarer des astreintes,
dans les exports aussi. Un réglage par utilisateur ajoute une durée
forfaitaire au temps travaillé de chaque jour d'astreinte déclaré.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
31
README.md
31
README.md
@ -181,18 +181,28 @@ demi-journées restantes.
|
||||
|
||||
## Astreinte & repos de 11h
|
||||
|
||||
Le bouton **AS** marque un jour d'astreinte. Ce jour et son lendemain (la nuit qui
|
||||
suit), 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 libre. Une fin antérieure au début (fin < début) désigne une
|
||||
intervention à cheval sur minuit.
|
||||
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
|
||||
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
|
||||
libre. Une fin antérieure au début (fin < début) désigne une intervention à
|
||||
cheval sur minuit. Une durée forfaitaire peut être ajoutée au temps travaillé de
|
||||
chaque jour d'astreinte déclaré (page **Réglages**, « Forfait par jour
|
||||
d'astreinte »).
|
||||
|
||||
La semaine affiche aussi **samedi et dimanche** (heures dues nulles, lignes
|
||||
grisées) : on peut y déclarer des astreintes, y saisir les interventions et les
|
||||
pointer si besoin — les exports `.ods` suivent.
|
||||
|
||||
L'app en déduit l'heure de retour au bureau : **fin du dernier événement + 11h de
|
||||
repos consécutif** (exigées par période de 24h), affichée sous la liste (« retour
|
||||
≥ 13:00 »). Fin 23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le
|
||||
jour même. Un repos qui se termine avant l'ouverture ne contraint rien.
|
||||
≥ 13:00 »). Tout quart d'heure entamé est travaillé : la fin est arrondie au quart
|
||||
d'heure supérieur (fin 23:37 → comptée 23:45) ; les interventions hors fenêtre
|
||||
18:00→18:00 ne sont pas de l'astreinte. Fin 23:30 → retour 10:30 le lendemain ;
|
||||
fin 02:00 → retour 13:00 le jour même. Un repos qui se termine avant l'ouverture
|
||||
ne contraint rien.
|
||||
|
||||
À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le
|
||||
décompte de la semaine :
|
||||
@ -212,7 +222,8 @@ 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. Les jours fériés français (fixes + Pâques, Ascension, Pentecôte)
|
||||
(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.
|
||||
|
||||
Ces règles sont ajustables dans `data/config.json`, clé `astreinte_facteurs` :
|
||||
|
||||
@ -9,10 +9,12 @@ DEFAULT_PLAGES = {
|
||||
"pause_dejeuner_fin": "13:30", # reprise par défaut projetée tant que l'entrée aprem n'est pas saisie
|
||||
"arrivee_visee": "", # arrivée du matin visée pour les préconisations (vide = matin_debut)
|
||||
"depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun)
|
||||
"astreinte_forfait": "", # durée ajoutée au temps travaillé de chaque jour d'astreinte déclaré (HH:MM, vide = aucune)
|
||||
}
|
||||
|
||||
REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau
|
||||
PAS_ASTREINTE_MIN = 15 # pas du décompte astreinte : toute tranche entamée compte en totalité
|
||||
ASTREINTE_DEBUT_MIN = 18 * 60 # un jour d'astreinte déclaré couvre 18:00 → 18:00 le lendemain
|
||||
|
||||
# Règles de comptage des heures d'astreinte : chaque tranche de 15 min est
|
||||
# attribuée à la première règle dont le type de jour et la fenêtre horaire la
|
||||
@ -56,6 +58,23 @@ 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:
|
||||
"""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é."""
|
||||
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
|
||||
return debut, fin
|
||||
|
||||
|
||||
def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) -> 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
|
||||
@ -144,7 +163,8 @@ def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None) -> dict:
|
||||
def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None,
|
||||
fenetre: tuple[int, int] | 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
|
||||
@ -153,6 +173,10 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
|
||||
suivant. Les tranches couvertes par plusieurs événements ne sont comptées qu'une
|
||||
fois dans les totaux 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.
|
||||
|
||||
Renvoie {tranches, total_min, total_h, total_pondere_min, total_pondere_h,
|
||||
events: [détail par événement], categories: [compteurs par règle]}.
|
||||
"""
|
||||
@ -175,6 +199,12 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
|
||||
continue
|
||||
deb_min, fin_min = hhmm_to_minutes(deb), hhmm_to_minutes(fin)
|
||||
duree = fin_min - deb_min if fin_min >= deb_min else 24 * 60 - deb_min + fin_min
|
||||
if fenetre is not None:
|
||||
debut_c = max(deb_min, fenetre[0])
|
||||
fin_c = min(deb_min + duree, fenetre[1])
|
||||
if fin_c <= debut_c:
|
||||
continue
|
||||
deb_min, duree = debut_c, fin_c - debut_c
|
||||
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
|
||||
quarts = list(range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
|
||||
a0 + duree, PAS_ASTREINTE_MIN))
|
||||
@ -232,13 +262,18 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
|
||||
|
||||
def retour_apres_repos(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None:
|
||||
"""Heure de retour au bureau après les 11h de repos consécutif qui suivent la
|
||||
dernière activité d'astreinte.
|
||||
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é. Le retour s'applique au jour où se termine le repos : fin
|
||||
23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le jour même.
|
||||
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
|
||||
entamé est travaillé : la fin est arrondie au quart d'heure supérieur avant
|
||||
d'ajouter le repos. Le retour s'applique au jour où se termine le repos :
|
||||
fin 23:30 → retour 10:30 le lendemain ; fin 23:37 (comptée 23:45) → retour
|
||||
10:45 ; fin 02:00 → retour 13:00 le jour même.
|
||||
Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour
|
||||
au-delà de l'ouverture."""
|
||||
astreintes = set(astreintes or [])
|
||||
@ -252,21 +287,30 @@ def retour_apres_repos(date_str: str, p_by_date: dict, plages: dict, astreintes)
|
||||
if deb and fin:
|
||||
yield hhmm_to_minutes(deb), hhmm_to_minutes(fin)
|
||||
|
||||
# Événements du jour : activité terminée aujourd'hui, le repos s'achève donc
|
||||
# aujourd'hui (une fin matinale est la fin d'une intervention nocturne du jour).
|
||||
if jour_astreinte_qualifie(date_str, astreintes):
|
||||
def _fin_comptee(fin: int) -> int:
|
||||
"""Fin d'activité effectivement travaillée : toute tranche de 15 min
|
||||
entamée compte, la fin est arrondie au quart d'heure supérieur."""
|
||||
return -(-fin // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
|
||||
|
||||
# Événements du jour, fin matinale : le matin du jour n'est de l'astreinte
|
||||
# que si la veille était déclarée AS (fenêtre 18:00→18:00) ; une fin matinale
|
||||
# y termine une intervention nocturne et le repos s'achève aujourd'hui.
|
||||
if veille in astreintes:
|
||||
for deb, fin in _fins(date_str):
|
||||
if fin >= deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
||||
cands.append(fin + REPOS_QUOTIDIEN_MIN)
|
||||
# Événements de la veille : fin de soirée → le repos se termine aujourd'hui ;
|
||||
# fin après minuit (fin < deb) → l'activité s'est achevée aujourd'hui à fin,
|
||||
# le repos s'achève donc aujourd'hui.
|
||||
if jour_astreinte_qualifie(veille, astreintes):
|
||||
fin_c = _fin_comptee(fin)
|
||||
if fin >= deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
||||
cands.append(fin_c + REPOS_QUOTIDIEN_MIN)
|
||||
# Événements de la veille déclarée AS : fin de soirée (après 18:00, dans la
|
||||
# fenêtre) → le repos se termine aujourd'hui ; fin après minuit (fin < deb) →
|
||||
# l'activité s'est achevée aujourd'hui à fin, le repos s'achève donc
|
||||
# aujourd'hui.
|
||||
if veille in astreintes:
|
||||
for deb, fin in _fins(veille):
|
||||
if fin >= deb and fin + REPOS_QUOTIDIEN_MIN >= 24 * 60:
|
||||
cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60)
|
||||
if fin < deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
||||
cands.append(fin + REPOS_QUOTIDIEN_MIN)
|
||||
fin_c = _fin_comptee(fin)
|
||||
if fin >= deb and fin_c > ASTREINTE_DEBUT_MIN and fin_c + REPOS_QUOTIDIEN_MIN >= 24 * 60:
|
||||
cands.append(fin_c + REPOS_QUOTIDIEN_MIN - 24 * 60)
|
||||
if fin < deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
||||
cands.append(fin_c + REPOS_QUOTIDIEN_MIN)
|
||||
if not cands:
|
||||
return None
|
||||
best = max(cands)
|
||||
@ -339,6 +383,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
"""Compute totals for a list of pointages (one week)."""
|
||||
plages = {**DEFAULT_PLAGES, **(plages or {})}
|
||||
astreintes = set(astreintes 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"])
|
||||
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
|
||||
@ -364,9 +409,11 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
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
|
||||
events = p.get("astreinte_events") or []
|
||||
dec = decompte_astreinte(p["date"], events, facteurs) if (jour_astreinte_qualifie(p["date"], astreintes) and events) else None
|
||||
dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes))
|
||||
if jour_astreinte_qualifie(p["date"], astreintes) and events else None)
|
||||
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages)
|
||||
travaille = heures_travaillees(p, retour_min)
|
||||
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_full = du + matin_ex + aprem_ex
|
||||
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex)
|
||||
@ -382,6 +429,8 @@ 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,
|
||||
"forfait_min": forfait,
|
||||
"forfait_h": minutes_to_hhmm(forfait) if forfait else "",
|
||||
"du_full_min": du_full,
|
||||
"matin_excl_min": matin_ex,
|
||||
"aprem_excl_min": aprem_ex,
|
||||
|
||||
@ -13,7 +13,7 @@ from models import (
|
||||
load_plages, heures_jour_min,
|
||||
)
|
||||
|
||||
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
|
||||
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
|
||||
HEADERS = [
|
||||
"Date", "Jour", "Matin entrée", "Matin sortie", "Aprem entrée", "Aprem sortie",
|
||||
"Travaillé", "Dû", "Δ jour", "Congé", "Astreinte", "Astreinte pondérée", "Retour",
|
||||
@ -22,7 +22,7 @@ HEADERS = [
|
||||
|
||||
def _week_dates(year: int, week: int) -> list[str]:
|
||||
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
|
||||
return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)]
|
||||
return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(7)]
|
||||
|
||||
|
||||
def _new_doc():
|
||||
|
||||
45
app/main.py
45
app/main.py
@ -32,7 +32,7 @@ from models import (
|
||||
from calcul import (
|
||||
compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues,
|
||||
heures_travaillees, jour_astreinte_qualifie, exclusion_retour,
|
||||
nominaux_du_jour, retour_apres_repos, decompte_astreinte,
|
||||
nominaux_du_jour, retour_apres_repos, decompte_astreinte, fenetre_astreinte_jour,
|
||||
)
|
||||
from stats import compute_all_stats
|
||||
from export import export_semaine_ods, export_annee_ods
|
||||
@ -125,7 +125,7 @@ templates = Jinja2Templates(directory="templates")
|
||||
templates.env.filters["zip"] = zip
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
|
||||
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
|
||||
_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$")
|
||||
@ -288,8 +288,10 @@ def _require_user(request: Request):
|
||||
# ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def week_dates(year: int, week: int) -> list[str]:
|
||||
"""Les 7 jours ISO de la semaine : le week-end est affiché (heures dues nulles)
|
||||
pour pouvoir y déclarer des astreintes et y saisir les interventions."""
|
||||
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
|
||||
return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)]
|
||||
return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(7)]
|
||||
|
||||
|
||||
def _weeks_by_year(all_weeks: list) -> dict:
|
||||
@ -327,7 +329,9 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
|
||||
# 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)
|
||||
trav = heures_travaillees(p, retour_min)
|
||||
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_full = du + matin_ex + aprem_ex
|
||||
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex)
|
||||
@ -335,7 +339,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
|
||||
# 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())
|
||||
dec = (decompte_astreinte(date, events, load_facteurs_astreinte(), fenetre_astreinte_jour(date, astreintes))
|
||||
if events and jour_astreinte_qualifie(date, astreintes) else None)
|
||||
return {
|
||||
**p,
|
||||
@ -344,6 +348,8 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
|
||||
"du": minutes_to_hhmm(du),
|
||||
"delta": minutes_to_hhmm(delta),
|
||||
"conge": conges_date,
|
||||
"forfait_min": forfait,
|
||||
"forfait_h": minutes_to_hhmm(forfait) if forfait else "",
|
||||
"du_full_min": du_full,
|
||||
"matin_excl_min": matin_ex, "aprem_excl_min": aprem_ex,
|
||||
"matin_nominal_min": matin_nom, "aprem_nominal_min": aprem_nom,
|
||||
@ -476,8 +482,11 @@ def _as_block_html(date, jour):
|
||||
rows = "".join(_as_event_html(date, ev) for ev in events) or _as_event_html(date, {})
|
||||
hint = ""
|
||||
if jour.get("retour_min") is not None:
|
||||
hint = (f'<span class="as-hint" title="11h de repos consécutif après la dernière activité">'
|
||||
hint = (f'<span class="as-hint" title="11h de repos consécutif après la dernière activité, fin arrondie au quart d\'heure entamé">'
|
||||
f'retour ≥ {_e(jour["retour"])}</span>')
|
||||
if jour.get("forfait_min"):
|
||||
hint += (f'<span class="as-hint" title="Forfait réglé dans Réglages, ajouté au temps travaillé">'
|
||||
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">'
|
||||
@ -941,9 +950,12 @@ def settings_page(request: Request):
|
||||
"arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee",
|
||||
"depart_url": f"{base_url}/presence/{notif['token']}/depart",
|
||||
"plages": plages,
|
||||
"facteurs": load_facteurs_astreinte(),
|
||||
"saved": request.query_params.get("saved") == "1",
|
||||
"plages_saved": request.query_params.get("plages_saved") == "1",
|
||||
"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",
|
||||
})
|
||||
|
||||
|
||||
@ -1085,6 +1097,27 @@ def settings_plages_save(
|
||||
return RedirectResponse("/settings?plages_saved=1", status_code=303)
|
||||
|
||||
|
||||
@app.post("/settings/astreinte-forfait", response_class=HTMLResponse)
|
||||
def settings_astreinte_forfait(request: Request, astreinte_forfait: str = Form("")):
|
||||
"""Durée forfaitaire ajoutée au temps travaillé de chaque jour d'astreinte
|
||||
déclaré (réglage par utilisateur, stocké avec ses plages)."""
|
||||
user_id = _get_user(request)
|
||||
if not user_id:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
val = astreinte_forfait.strip()
|
||||
if val and (not _HHMM_RE.match(val) or hhmm_to_minutes(val) > 12 * 60):
|
||||
logger.warning("réglages forfait astreinte rejetés (invalide): user_id=%r valeur=%r", user_id, val)
|
||||
return RedirectResponse("/settings?forfait_error=1", status_code=303)
|
||||
|
||||
plages = load_plages(user_id)
|
||||
plages["astreinte_forfait"] = val
|
||||
save_plages(user_id, plages)
|
||||
logger.info("réglages forfait astreinte mis à jour: user_id=%r durée=%r", user_id, val or "(aucune)")
|
||||
append_log(user_id, "settings", "Forfait astreinte", f"durée={val or '(aucune)'}")
|
||||
return RedirectResponse("/settings?forfait_saved=1", status_code=303)
|
||||
|
||||
|
||||
@app.post("/presence/{token}/arrivee")
|
||||
def presence_arrivee(request: Request, token: str):
|
||||
user_id = find_user_by_token(token)
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
(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 }}">
|
||||
<tr id="row-{{ j.date }}"{% if nom in ['Samedi', 'Dimanche'] %} class="we"{% endif %}>
|
||||
|
||||
<td class="col-jour">
|
||||
<span class="jour-nom">{{ nom }}</span>
|
||||
@ -117,7 +117,7 @@
|
||||
|
||||
{% if j.astreinte_qualifiee %}
|
||||
{% set nb_as = (j.astreinte_events or []) | length %}
|
||||
<tr class="as-row">
|
||||
<tr class="as-row{% if nom in ['Samedi', 'Dimanche'] %} we{% endif %}">
|
||||
<td colspan="9">
|
||||
<div id="as-block-{{ j.date }}" class="as-block" data-date="{{ j.date }}" data-label="Astreinte">
|
||||
<div class="as-head">
|
||||
@ -125,7 +125,8 @@
|
||||
onclick="asToggle(this)">
|
||||
<span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
|
||||
</button>
|
||||
{% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité">retour ≥ {{ j.retour }}</span>{% endif %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class="as-body">
|
||||
@ -166,5 +167,5 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="as-row"><td colspan="9"><div id="as-block-{{ j.date }}" class="as-block"></div></td></tr>
|
||||
<tr class="as-row{% if nom in ['Samedi', 'Dimanche'] %} we{% endif %}"><td colspan="9"><div id="as-block-{{ j.date }}" class="as-block"></div></td></tr>
|
||||
{% endif %}
|
||||
|
||||
@ -320,6 +320,12 @@
|
||||
tbody tr:hover .td-fill.cg { background: var(--conge-bg); }
|
||||
tbody tr:not(:last-child) td { border-bottom: 1px solid var(--rule-l); }
|
||||
|
||||
/* Week-end : affiché pour déclarer des astreintes, mais hors heures dues */
|
||||
tbody tr.we td { background: var(--ground); }
|
||||
tbody tr.we:hover td, tbody tr.we:hover .td-fill { background: var(--ground); }
|
||||
tbody tr.we .jour-nom { color: var(--muted); font-weight: 400; }
|
||||
tbody tr.we .calc-num, tbody tr.we .delta-zero { color: var(--rule); }
|
||||
|
||||
td {
|
||||
padding: .5rem .65rem;
|
||||
vertical-align: middle;
|
||||
|
||||
@ -50,6 +50,52 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<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>).
|
||||
</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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">Forfait par jour d'astreinte</span>
|
||||
<p class="settings-p">
|
||||
Durée ajoutée automatiquement au temps travaillé de chaque jour d'astreinte
|
||||
déclaré (bouton <b>AS</b>), en plus des interventions saisies. Elle compte dans
|
||||
le bilan de la semaine ; le lendemain de l'astreinte n'est pas concerné.
|
||||
Vide = pas de forfait.
|
||||
</p>
|
||||
|
||||
{% if forfait_saved %}
|
||||
<div class="settings-saved">Enregistré.</div>
|
||||
{% endif %}
|
||||
{% if forfait_error %}
|
||||
<div class="settings-error">Durée invalide (format HH:MM, 12h00 maximum).</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/astreinte-forfait" class="settings-form">
|
||||
<label class="login-label" for="astreinte_forfait">Durée ajoutée par jour d'astreinte</label>
|
||||
<input id="astreinte_forfait" class="login-input time-range-input" type="time" step="900"
|
||||
name="astreinte_forfait" value="{{ plages.astreinte_forfait }}">
|
||||
<button class="login-btn" type="submit">Enregistrer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">Notifications (ntfy) <a href="/aide" class="settings-help-link">Aide</a></span>
|
||||
<p class="settings-p">
|
||||
@ -173,6 +219,30 @@
|
||||
}
|
||||
.settings-p { font-size: 13px; color: var(--text); line-height: 1.6; }
|
||||
.settings-p a { color: var(--accent); }
|
||||
.settings-p code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--ground);
|
||||
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-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); }
|
||||
@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; }
|
||||
}
|
||||
.settings-form { display: flex; flex-direction: column; gap: .5rem; max-width: 420px; }
|
||||
.settings-saved {
|
||||
background: var(--pos-bg);
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""Astreinte : événements, décompte par tranches de 15 min, repos de 11h et retour décalé."""
|
||||
from calcul import (
|
||||
compute_week, decompte_astreinte, heures_dues, heures_travaillees, hhmm_to_minutes,
|
||||
jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour,
|
||||
retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN,
|
||||
jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, fenetre_astreinte_jour,
|
||||
retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN, ASTREINTE_DEBUT_MIN,
|
||||
)
|
||||
import models
|
||||
|
||||
@ -82,12 +82,30 @@ def test_retour_fin_nocturne_sur_le_lendemain():
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60
|
||||
|
||||
|
||||
def test_retour_activite_matinale_sur_le_jour_meme():
|
||||
"""Event 05:00→06:00 sur le jour d'astreinte : l'activité s'est réellement
|
||||
terminée à 06:00 ce jour-là → repos jusqu'à 17:00 le même jour."""
|
||||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||||
def test_retour_activite_matinale_sur_le_lendemain_qualifie():
|
||||
"""Event 05:00→06:00 saisi sur le lendemain de l'astreinte : le matin du
|
||||
lendemain est dans la fenêtre 18:00→18:00, l'activité s'est terminée à 06:00
|
||||
ce jour-là → repos jusqu'à 17:00 le même jour."""
|
||||
p_by_date = {"2026-07-15": _pointage("2026-07-15",
|
||||
astreinte_events=[{"debut": "05:00", "fin": "06:00"}])}
|
||||
assert retour_apres_repos("2026-07-14", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
|
||||
|
||||
|
||||
def test_retour_intervention_avant_18h_sur_le_jour_as_ignoree():
|
||||
"""L'astreinte couvre 18:00 → 18:00 : une intervention du matin du jour
|
||||
déclaré (avant 18:00) n'est pas de l'astreinte et ne déclenche pas de repos."""
|
||||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||||
astreinte_events=[{"debut": "10:00", "fin": "12:00"}])}
|
||||
assert retour_apres_repos("2026-07-14", p_by_date, PLAGES, ["2026-07-14"]) is None
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
|
||||
|
||||
|
||||
def test_retour_intervention_finissant_avant_18h_la_veille_ignoree():
|
||||
"""Event 16:00→17:30 la veille déclarée AS : fin 17:30 arrondie 17:30, avant
|
||||
le début de la fenêtre (18:00) → pas de contrainte de retour."""
|
||||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||||
astreinte_events=[{"debut": "16:00", "fin": "17:30"}])}
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
|
||||
|
||||
|
||||
def test_retour_fin_du_soir_sur_le_jour_d_astreinte():
|
||||
@ -98,6 +116,21 @@ def test_retour_fin_du_soir_sur_le_jour_d_astreinte():
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 30
|
||||
|
||||
|
||||
def test_retour_fin_hors_quart_arrondie_au_quart_entame():
|
||||
"""Event 21:00→23:37 : le quart d'heure entamé est travaillé, fin comptée
|
||||
23:45 → repos terminé 10:45 le lendemain."""
|
||||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||||
astreinte_events=[{"debut": "21:00", "fin": "23:37"}])}
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 45
|
||||
|
||||
|
||||
def test_retour_fin_nocturne_hors_quart_arrondie():
|
||||
"""Event 23:00→01:07 (à cheval sur minuit) : fin comptée 01:15 → retour 12:15."""
|
||||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||||
astreinte_events=[{"debut": "23:00", "fin": "01:07"}])}
|
||||
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60 + 15
|
||||
|
||||
|
||||
def test_retour_event_a_cheval_sur_minuit_saisi_la_veille():
|
||||
"""Event 23:00→01:00 (fin < début) saisi la veille : fin réelle 01:00 le jour
|
||||
même → 01:00 + 11h = 12:00."""
|
||||
@ -387,3 +420,103 @@ def test_toggle_astreinte_persiste():
|
||||
def test_load_facteurs_astreinte_defaut_si_absent():
|
||||
facteurs = models.load_facteurs_astreinte()
|
||||
assert any(f["nom"] == "Nuit" for f in facteurs)
|
||||
|
||||
|
||||
# ── Fenêtre d'astreinte 18:00 → 18:00 ───────────────────────────────────────
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_decompte_hors_fenetre_18h_18h_ignore():
|
||||
# Matin du jour AS déclaré : avant 18:00, hors astreinte
|
||||
d = decompte_astreinte("2026-07-14", [{"debut": "10:00", "fin": "12:00"}],
|
||||
None, (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN))
|
||||
assert d["total_min"] == 0
|
||||
# Lendemain après 18:00 : hors astreinte
|
||||
d = decompte_astreinte("2026-07-15", [{"debut": "19:00", "fin": "20:00"}],
|
||||
None, (0, ASTREINTE_DEBUT_MIN))
|
||||
assert d["total_min"] == 0
|
||||
# Lendemain matin : dans la fenêtre
|
||||
d = decompte_astreinte("2026-07-15", [{"debut": "01:00", "fin": "02:00"}],
|
||||
None, (0, ASTREINTE_DEBUT_MIN))
|
||||
assert d["total_min"] == 60
|
||||
# Événement à cheval sur la borne 18:00 du lendemain : seul le fragment
|
||||
# intérieur compte (17:00→18:00 = 4 tranches)
|
||||
d = decompte_astreinte("2026-07-15", [{"debut": "17:00", "fin": "19:00"}],
|
||||
None, (0, ASTREINTE_DEBUT_MIN))
|
||||
assert d["total_min"] == 60
|
||||
|
||||
|
||||
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)."""
|
||||
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"),
|
||||
]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
|
||||
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["astreinte"]["total_min"] == 60
|
||||
assert result["astreinte"]["total_pondere_min"] == 120 # 14 juillet : férié ×2
|
||||
|
||||
|
||||
def test_compute_week_astreinte_samedi_dimanche(freeze_today):
|
||||
"""Samedi AS avec intervention nocturne : dû 0 (week-end), décompte en
|
||||
compteur astreinte (Nuit vendredi ×1,5 puis Samedi ×1,5 sur 08:00→20:00)."""
|
||||
pointages = [
|
||||
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
|
||||
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
|
||||
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
|
||||
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
|
||||
_pointage("2026-07-17", "09:00", "12:24", "13:30", "17:54"),
|
||||
_pointage("2026-07-18", astreinte_events=[{"debut": "23:00", "fin": "09:00"}]),
|
||||
_pointage("2026-07-19"),
|
||||
]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-18"])
|
||||
samedi = result["jours"][5]
|
||||
assert samedi["du_min"] == 0
|
||||
assert samedi["astreinte_min"] == 10 * 60 # 23:00→09:00 = 10h
|
||||
assert result["total_du"] == "39h00" # week-end hors dues
|
||||
|
||||
|
||||
# ── Forfait par jour d'astreinte déclaré ───────────────────────────────────────
|
||||
|
||||
def test_compute_week_forfait_ajoute_au_travaille_du_jour_declare(freeze_today):
|
||||
"""Forfait 1h00 réglé : ajouté au travaillé du jour AS déclaré uniquement
|
||||
(pas du lendemain), sans toucher aux heures dues."""
|
||||
plages = {**PLAGES, "astreinte_forfait": "01:00"}
|
||||
pointages = [
|
||||
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
|
||||
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
|
||||
_pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"),
|
||||
]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN, plages, ["2026-07-14"])
|
||||
mardi = result["jours"][1]
|
||||
assert mardi["forfait_min"] == 60
|
||||
assert mardi["travaille_min"] == HEURES_JOUR_MIN + 60
|
||||
assert result["jours"][2]["forfait_min"] == 0 # lendemain : rien
|
||||
assert result["jours"][0]["forfait_min"] == 0 # jour sans astreinte
|
||||
assert result["total_travaille"] == "16h36" # 2 × 7h48 + 1h
|
||||
|
||||
|
||||
def test_compute_week_sans_forfait_comportement_inchange(freeze_today):
|
||||
pointages = [
|
||||
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
|
||||
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54",
|
||||
astreinte_events=[{"debut": "21:00", "fin": "22:00"}]),
|
||||
_pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"),
|
||||
]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
|
||||
assert result["jours"][1]["forfait_min"] == 0
|
||||
assert result["total_travaille"] == "15h36"
|
||||
|
||||
Reference in New Issue
Block a user