Astreinte : repos hebdomadaire de 35h en option (7 jours glissants)

Un week-end d'astreinte fragmenté peut ne contenir aucun trou de 35h
consécutives sans activité : le retour est alors repoussé à dernière
activité + 35h, et toute nouvelle intervention redémarre le décompte.
Désactivé par défaut, activable par compte dans Réglages ; la journée
couverte est créditée comme pour les 11h quotidiennes.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-09 21:49:19 +02:00
parent 77ae115c4f
commit f6fef650a7
6 changed files with 471 additions and 17 deletions

View File

@ -34,7 +34,8 @@ 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, couvert_detail,
nominaux_du_jour, retour_apres_repos, decompte_astreinte, fenetre_astreinte_jour,
nominaux_du_jour, retour_apres_repos, retour_repos, retour_txt, decompte_astreinte,
fenetre_astreinte_jour,
)
from stats import compute_all_stats
from export import export_semaine_ods, export_annee_ods
@ -304,6 +305,16 @@ def _weeks_by_year(all_weeks: list) -> dict:
return result
def _contexte_semaine_precedente(year: int, week: int, dates: list[str], user_id: str) -> list[dict]:
"""Pointages de la semaine ISO précédente (jours avant la semaine affichée) :
la fenêtre glissante de 7 jours du repos hebdomadaire de 35h déborde sur la
semaine d'avant."""
prev = (datetime.strptime(dates[0], "%Y-%m-%d").date() - timedelta(days=7)).isocalendar()
if (prev.year, prev.week) == (year, week):
return []
return [r for r in load_week_pointages(prev.year, prev.week, user_id) if r.get("date") < dates[0]]
def _build_soldes_ctx(year, week, conges, h_jour, user_id):
dates = week_dates(year, week)
raw = load_week_pointages(year, week, user_id)
@ -313,7 +324,8 @@ 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_effectives(user_id), load_facteurs_astreinte(), load_feries(user_id))
contexte = _contexte_semaine_precedente(year, week, dates, user_id)
result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes_effectives(user_id), load_facteurs_astreinte(), load_feries(user_id), contexte)
result["heures_jour"] = minutes_to_hhmm(h_jour)
# Panneau « aujourd'hui » : uniquement si le jour courant appartient à la
# semaine affichée (re-rendu à chaque enregistrement via _soldes.html).
@ -341,13 +353,20 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
astreintes = set(load_astreintes_effectives(user_id))
feries = set(load_feries(user_id))
p_by_date = {p["date"]: p for p in raw}
# Repos hebdomadaire de 35h (7 jours glissants) : la fenêtre des 6 jours
# précédents peut déborder sur la semaine ISO précédente.
debut_fenetre = (d - timedelta(days=6)).isocalendar()
if (debut_fenetre.year, debut_fenetre.week) != (iso.year, iso.week):
for row in load_week_pointages(debut_fenetre.year, debut_fenetre.week, user_id):
p_by_date.setdefault(row.get("date"), row)
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)
# Retour au bureau après repos, calculé depuis la fin de la dernière
# activité d'astreinte (11h quotidiennes) et, si activée, la contrainte de
# repos hebdomadaire de 35h sur 7 jours glissants
retour_min = retour_repos(date, p_by_date, plages, astreintes)
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
@ -385,7 +404,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
"astreinte": date in astreintes,
"astreinte_qualifiee": jour_astreinte_qualifie(date, astreintes),
"retour_min": retour_min,
"retour": minutes_to_time(retour_min) if retour_min is not None else None,
"retour": retour_txt(retour_min, date) if retour_min is not None else None,
"astreinte_min": dec["total_min"] if dec else 0,
"astreinte_h": dec["total_h"] if dec else "",
}
@ -394,7 +413,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
def _oob_calc(date, jour):
trav_inner = f'<span class="calc-num">{"—" if not jour["travaille_min"] else _e(jour["travaille"])}</span>'
if jour.get("couvert_min"):
trav_inner += (f'<span class="calc-couvert" title="Présence obligatoire couverte par le repos de 11h '
trav_inner += (f'<span class="calc-couvert" title="Présence obligatoire couverte par le repos (11h quotidiennes, ou 35h hebdomadaires si activées) '
f'({_e(jour["couvert_detail"])}) : comptée comme effectuée">+{_e(jour["couvert_h"])} repos</span>')
if jour["travaille_min"]:
cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero")
@ -548,7 +567,7 @@ 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é (durée arrondie au quart d\'heure supérieur), retour arrondi au quart d\'horloge supérieur : :00, :15, :30 ou :45">'
hint = (f'<span class="as-hint" title="Repos après la dernière activité : 11h consécutives (quotidien), et 35h sur 7 jours glissants si le repos hebdomadaire est activé (Réglages). Toute intervention pendant le repos redémarre le décompte ; retour arrondi au quart d\'horloge supérieur : :00, :15, :30 ou :45">'
f'retour ≥ {_e(jour["retour"])}</span>')
if jour.get("couvert_min"):
hint += (f'<span class="as-hint" title="Présence obligatoire couverte par le repos ({_e(jour["couvert_detail"])}) : comptée comme effectuées dans la colonne Travaillé">'
@ -809,7 +828,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_effectives(user_id), load_facteurs_astreinte(), load_feries(user_id))
result = compute_week(pointages, conges, h_jour, plages, load_astreintes_effectives(user_id), load_facteurs_astreinte(), load_feries(user_id), _contexte_semaine_precedente(year, week, dates, 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)
@ -1052,6 +1071,7 @@ def settings_page(request: Request):
"forfait_saved": request.query_params.get("forfait_saved") == "1",
"forfait_error": request.query_params.get("forfait_error") == "1",
"astreinte_saved": request.query_params.get("astreinte_saved") == "1",
"repos_hebdo_saved": request.query_params.get("repos_hebdo_saved") == "1",
"facteurs_saved": request.query_params.get("facteurs_saved") == "1",
"facteurs_error": request.query_params.get("facteurs_error") == "1",
})
@ -1172,6 +1192,28 @@ def settings_astreinte_active(request: Request, astreinte_active: str = Form("")
return RedirectResponse("/settings?astreinte_saved=1", status_code=303)
@app.post("/settings/repos-hebdo", response_class=HTMLResponse)
def settings_repos_hebdo(request: Request, repos_hebdo_active: str = Form("")):
"""Préférence personnelle : respect du repos hebdomadaire de 35h (24h + 11h
consécutives, une fois par période de 7 jours glissants). Activée, un
week-end d'astreinte sans trou de 35h repousse l'heure de retour à dernière
activité + 35h — toute nouvelle intervention avant cette échéance
redémarre le décompte."""
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
actif = repos_hebdo_active == "1"
plages = load_plages(user_id)
plages["repos_hebdo_active"] = actif
save_plages(user_id, plages)
logger.info("repos hebdomadaire 35h %s: user_id=%r", "activé" if actif else "désactivé", user_id)
append_log(user_id, "settings", "Repos hebdomadaire 35h",
"activé (retour repoussé à dernière activité + 35h quand la semaine glissante n'a pas son repos)"
if actif else "désactivé (seul le repos quotidien de 11h décale le retour)")
return RedirectResponse("/settings?repos_hebdo_saved=1", status_code=303)
@app.post("/settings/plages", response_class=HTMLResponse)
def settings_plages_save(
request: Request,