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:
24
README.md
24
README.md
@ -242,6 +242,30 @@ est **comptée comme effectuée** :
|
||||
décalée d'autant, sans avertissement de retard) ;
|
||||
- une demi-journée entièrement couverte par le repos est considérée comme remplie.
|
||||
|
||||
### Repos hebdomadaire de 35h (option)
|
||||
|
||||
La loi exige en plus, **une fois par période de 7 jours glissants**, un repos de
|
||||
**35h consécutives** (24h de repos hebdomadaire + 11h de repos quotidien). Une
|
||||
semaine normale le satisfait d'elle-même — le week-end offre le trou nécessaire.
|
||||
Un **week-end d'astreinte fragmenté** par plusieurs interventions, lui, peut ne
|
||||
plus contenir aucun trou de 35h.
|
||||
|
||||
La prise en compte est une **préférence personnelle** (Réglages → « Repos
|
||||
hebdomadaire de 35h », désactivée par défaut). Activée :
|
||||
|
||||
- quand aucune période de 35h sans activité (pointages + interventions) n'existe
|
||||
dans les 7 derniers jours, l'heure de retour devient **dernière activité +
|
||||
35h** — à la place des 11h si elle est plus tardive ;
|
||||
- **toute nouvelle perturbation avant cette échéance redémarre le décompte** à
|
||||
zéro (le retour se recalcule depuis la nouvelle dernière activité) ;
|
||||
- un repos qui déborde sur le(s) jour(s) suivant(s) affiche le jour visé
|
||||
(« retour ≥ Mar 05:00 ») et couvre la journée de la même façon que les 11h
|
||||
(présence obligatoire créditée, rien à planifier).
|
||||
|
||||
La fenêtre glissante puise aussi dans la semaine précédente : les jours
|
||||
manquants y sont lus automatiquement ; sans eux, le début de la fenêtre compte
|
||||
comme repos (jamais de faux positif).
|
||||
|
||||
Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles
|
||||
sont décomptées à part — la **durée réelle** de chaque intervention est **arrondie
|
||||
au quart d'heure supérieur** (2, 5 ou 12 min réelles → 15 min ; 22 ou 28 → 30 min),
|
||||
|
||||
137
app/calcul.py
137
app/calcul.py
@ -11,9 +11,11 @@ DEFAULT_PLAGES = {
|
||||
"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)
|
||||
"astreinte_active": True, # préférence personnelle : ce compte gère-t-il les astreintes ?
|
||||
"repos_hebdo_active": False, # préférence personnelle : respecter le repos hebdomadaire de 35h (7 jours glissants)
|
||||
}
|
||||
|
||||
REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau
|
||||
REPOS_HEBDO_MIN = 35 * 60 # repos hebdomadaire : 24h + 11h consécutives, une fois par 7 jours glissants
|
||||
PAS_ASTREINTE_MIN = 15 # granularité du décompte astreinte : durée réelle arrondie au quart d'heure supérieur
|
||||
ASTREINTE_DEBUT_MIN = 18 * 60 # un jour d'astreinte déclaré couvre 18:00 → 18:00 le lendemain
|
||||
|
||||
@ -381,6 +383,118 @@ dernière activité d'astreinte.
|
||||
return best if best > hhmm_to_minutes(plages["matin_debut"]) else None
|
||||
|
||||
|
||||
# ── Repos hebdomadaire : 35h consécutives par 7 jours glissants ────────────────
|
||||
|
||||
def _activites_repos(p_by_date: dict, astreintes, jour0: date) -> list[tuple[int, int]]:
|
||||
"""Intervalles d'activité (minutes absolues depuis _EPOCH_DATE) autour du
|
||||
jour : créneaux pointés matin/après-midi, et interventions d'astreinte des
|
||||
jours qualifiés — la fin portée à la durée arrondie au quart d'heure
|
||||
supérieur, à cheval sur minuit le cas échéant. Une demi-journée entamée
|
||||
aujourd'hui (entrée sans sortie) court jusqu'à maintenant. Intervalles
|
||||
fusionnés et triés."""
|
||||
today = datetime.today()
|
||||
today_str = today.strftime("%Y-%m-%d")
|
||||
now_min = today.hour * 60 + today.minute
|
||||
intervals = []
|
||||
for i in range(-7, 2):
|
||||
d = jour0 + timedelta(days=i)
|
||||
row = p_by_date.get(d.isoformat())
|
||||
if not row:
|
||||
continue
|
||||
base = (d - _EPOCH_DATE).days * 1440
|
||||
for deb_k, fin_k in (("matin_entree", "matin_sortie"), ("aprem_entree", "aprem_sortie")):
|
||||
deb, fin = row.get(deb_k), row.get(fin_k)
|
||||
if deb and fin:
|
||||
intervals.append((base + hhmm_to_minutes(deb), base + hhmm_to_minutes(fin)))
|
||||
elif deb and d.isoformat() == today_str:
|
||||
intervals.append((base + hhmm_to_minutes(deb), base + now_min))
|
||||
if jour_astreinte_qualifie(d.isoformat(), astreintes):
|
||||
for ev in row.get("astreinte_events") or []:
|
||||
deb, fin = ev.get("debut"), ev.get("fin")
|
||||
if not (deb and fin):
|
||||
continue
|
||||
deb_m, fin_m = hhmm_to_minutes(deb), hhmm_to_minutes(fin)
|
||||
duree = fin_m - deb_m if fin_m >= deb_m else 24 * 60 - deb_m + fin_m
|
||||
fin_c = -(-duree // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
|
||||
intervals.append((base + deb_m, base + deb_m + fin_c))
|
||||
intervals.sort()
|
||||
fusionnes = []
|
||||
for deb, fin in intervals:
|
||||
if fin <= deb:
|
||||
continue
|
||||
if fusionnes and deb <= fusionnes[-1][1]:
|
||||
if fin > fusionnes[-1][1]:
|
||||
fusionnes[-1][1] = fin
|
||||
else:
|
||||
fusionnes.append([deb, fin])
|
||||
return [(deb, fin) for deb, fin in fusionnes]
|
||||
|
||||
|
||||
def retour_repos_hebdo(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None:
|
||||
"""Repos hebdomadaire : 35h consécutives (24h de repos hebdomadaire + 11h de
|
||||
repos quotidien) à obtenir une fois par période de 7 jours glissants. Une
|
||||
semaine normale le satisfait d'elle-même — le week-end offre le trou
|
||||
nécessaire ; un week-end d'astreinte fragmenté par plusieurs interventions
|
||||
peut ne plus contenir aucun trou de 35h : le retour au bureau est alors
|
||||
repoussé à dernière activité + 35h, et toute nouvelle perturbation avant
|
||||
cette échéance redémarre le décompte.
|
||||
|
||||
Sont comptées comme activité, sur la fenêtre [J-6 00:00, J+1 00:00[ : les
|
||||
créneaux pointés et les interventions d'astreinte des jours qualifiés (une
|
||||
demi-journée en cours aujourd'hui court jusqu'à maintenant). Un trou de 35h
|
||||
ou plus ENTRE deux activités (ou avant la première) valide le repos ; le
|
||||
trou final, après la dernière activité, ne compte pas : il ne deviendra
|
||||
repos que si l'heure de retour le respecte.
|
||||
|
||||
Préférence personnelle (Réglages) : désactivée, ne renvoie jamais rien.
|
||||
Renvoie des minutes depuis minuit du jour — possiblement ≥ 1440 quand le
|
||||
repos déborde sur les jours suivants (le jour courant est alors entièrement
|
||||
couvert) — ou None."""
|
||||
if not plages.get("repos_hebdo_active"):
|
||||
return None
|
||||
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
jour0_abs = (jour0 - _EPOCH_DATE).days * 1440
|
||||
fenetre_debut = jour0_abs - 6 * 1440
|
||||
fenetre_fin = jour0_abs + 2 * 1440
|
||||
actifs = [(max(deb, fenetre_debut), min(fin, fenetre_fin))
|
||||
for deb, fin in _activites_repos(p_by_date, astreintes, jour0)
|
||||
if fin > fenetre_debut and deb < fenetre_fin]
|
||||
if not actifs:
|
||||
return None
|
||||
fin_prec = None
|
||||
for deb, fin in actifs:
|
||||
trou_debut = fenetre_debut if fin_prec is None else fin_prec
|
||||
if deb - trou_debut >= REPOS_HEBDO_MIN:
|
||||
return None # repos hebdomadaire déjà pris dans la fenêtre glissante
|
||||
fin_prec = fin
|
||||
repos_fin = -(-(actifs[-1][1] + REPOS_HEBDO_MIN) // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
|
||||
rel = repos_fin - jour0_abs
|
||||
if rel < 1440 and rel <= hhmm_to_minutes(plages["matin_debut"]):
|
||||
return None # le repos s'achève avant l'ouverture : aucune contrainte
|
||||
return rel
|
||||
|
||||
|
||||
def retour_repos(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None:
|
||||
"""Heure de retour au bureau la plus tôt admise : repos quotidien de 11h
|
||||
(toujours) et, si activée, contrainte de repos hebdomadaire de 35h — la
|
||||
plus tardive des deux l'emporte. Minutes depuis minuit du jour, ≥ 1440 si
|
||||
le repos déborde sur les jours suivants, ou None."""
|
||||
candidats = [r for r in (
|
||||
retour_apres_repos(date_str, p_by_date, plages, astreintes),
|
||||
retour_repos_hebdo(date_str, p_by_date, plages, astreintes),
|
||||
) if r is not None]
|
||||
return max(candidats) if candidats else None
|
||||
|
||||
|
||||
def retour_txt(retour_min: int, date_str: str) -> str:
|
||||
"""« 13:00 », ou « Mar 07:00 » quand le retour déborde sur un jour suivant."""
|
||||
if retour_min < 1440:
|
||||
return minutes_to_time(retour_min)
|
||||
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
d = jour0 + timedelta(days=retour_min // 1440)
|
||||
return f"{JOURS_COURTS[d.weekday()]} {minutes_to_time(retour_min % 1440)}"
|
||||
|
||||
|
||||
def heures_travaillees(p: dict, retour_min: int | None = None) -> int:
|
||||
"""Return worked minutes for a pointage row.
|
||||
|
||||
@ -443,8 +557,12 @@ 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, feries: list[str] | set[str] | None = None) -> dict:
|
||||
"""Compute totals for a list of pointages (one week)."""
|
||||
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, contexte: list[dict] | None = None) -> dict:
|
||||
"""Compute totals for a list of pointages (one week).
|
||||
|
||||
`contexte` : pointages des jours précédant la semaine (semaine ISO
|
||||
précédente en pratique) — la fenêtre glissante de 7 jours du repos
|
||||
hebdomadaire de 35h déborde sur la semaine d'avant."""
|
||||
plages = {**DEFAULT_PLAGES, **(plages or {})}
|
||||
astreintes = set(astreintes or [])
|
||||
feries = set(feries or [])
|
||||
@ -460,6 +578,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
total_du = 0
|
||||
jours = []
|
||||
p_by_date = {p["date"]: p for p in pointages}
|
||||
p_ctx = {r["date"]: r for r in (contexte or []) if r.get("date")}
|
||||
p_ctx.update(p_by_date)
|
||||
# Cumuls astreinte de la semaine, suivis à part : les heures astreinte ne
|
||||
# comptent ni dans travaille ni dans du (compteurs séparés pondérés).
|
||||
as_jours = []
|
||||
@ -471,9 +591,10 @@ 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)
|
||||
# Retour au bureau après repos, calculé depuis l'heure de 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(p["date"], p_ctx, plages, astreintes)
|
||||
# Décompte astreinte du jour : événements multiples, durée réelle arrondie
|
||||
# au quart d'heure supérieur ; un férié marqué à la main qualifie les
|
||||
# minutes pour les règles « ferie »
|
||||
@ -526,7 +647,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
"astreinte_h": dec["total_h"] if dec else "",
|
||||
"astreinte_pondere_min": dec["total_pondere_min"] if dec else 0,
|
||||
"retour_min": retour_min,
|
||||
"retour": minutes_to_time(retour_min) if retour_min is not None else None,
|
||||
"retour": retour_txt(retour_min, p["date"]) if retour_min is not None else None,
|
||||
})
|
||||
# Jours repris dans le récapitulatif d'astreinte : ceux avec des heures
|
||||
# décomptées, mais aussi les jours AS déclarés portant un forfait (sans
|
||||
@ -628,6 +749,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
du = jour["du_min"]
|
||||
if du == 0:
|
||||
continue
|
||||
# Jour entièrement couvert par un repos hebdomadaire qui déborde
|
||||
# (retour au-delà de minuit suivant) : rien à planifier ce jour-là.
|
||||
if (jour.get("retour_min") or 0) >= 24 * 60:
|
||||
continue
|
||||
matin_nominal = jour["matin_nominal_min"]
|
||||
aprem_nominal = jour["aprem_nominal_min"]
|
||||
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg
|
||||
|
||||
60
app/main.py
60
app/main.py
@ -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,
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
<td class="col-calc" data-label="Travaillé">
|
||||
<span id="calc-{{ j.date }}-trav">
|
||||
<span class="calc-num">{{ j.travaille if j.travaille_min else '—' }}</span>
|
||||
{% if j.couvert_min %}<span class="calc-couvert" title="Présence obligatoire couverte par le repos de 11h ({{ j.couvert_detail }}) : comptée comme effectuée">+{{ j.couvert_h }} repos</span>{% endif %}
|
||||
{% if j.couvert_min %}<span class="calc-couvert" title="Présence obligatoire couverte par le repos (11h quotidiennes, ou 35h hebdomadaires si activées) ({{ j.couvert_detail }}) : comptée comme effectuée">+{{ j.couvert_h }} repos</span>{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@ -146,7 +146,7 @@
|
||||
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é (durée arrondie au quart d'heure supérieur), retour arrondi au quart d'horloge supérieur : :00, :15, :30 ou :45">retour ≥ {{ j.retour }}</span>{% endif %}
|
||||
{% if j.retour_min is not none %}<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">retour ≥ {{ j.retour }}</span>{% endif %}
|
||||
{% if j.couvert_min %}<span class="as-hint" title="Présence obligatoire couverte par le repos ({{ j.couvert_detail }}) : comptée comme effectuées dans la colonne Travaillé">+{{ j.couvert_h }} de présence comptées</span>{% endif %}
|
||||
{% if j.forfait_min %}<span class="as-hint" title="Forfait du jour AS déclaré (Réglages) : suivi dans le récapitulatif d'astreinte, hors heures travaillées">forfait {{ j.forfait_h }}</span>{% endif %}
|
||||
{% if j.astreinte_min %}<span class="as-total" title="Durée réelle des interventions arrondie au quart d'heure supérieur (2, 5 ou 12 min → 15 ; 22 ou 28 → 30), même sans tomber sur un quart d'heure de l'horloge — compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_h }}</span>{% endif %}
|
||||
|
||||
@ -72,6 +72,35 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if plages.astreinte_active %}
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">Repos hebdomadaire de 35h</span>
|
||||
<p class="settings-p">
|
||||
La loi impose, une fois par période de <b>7 jours glissants</b>, un repos de
|
||||
<b>35h consécutives</b> (24h de repos hebdomadaire + 11h de repos quotidien).
|
||||
Un week-end normal le satisfait tout seul ; un week-end d'astreinte fragmenté
|
||||
par plusieurs interventions, pas forcément. Activée, cette option repousse
|
||||
l'heure de retour à <b>dernière activité + 35h</b> quand aucune période de
|
||||
35h sans activité n'existe dans les 7 derniers jours — et toute nouvelle
|
||||
intervention avant cette échéance <b>redémarre</b> le décompte. La présence
|
||||
couverte par ce repos est créditée comme effectuée, comme pour les 11h
|
||||
quotidiennes. Désactivée (défaut), seul le repos de 11h décale le retour.
|
||||
</p>
|
||||
|
||||
{% if repos_hebdo_saved %}
|
||||
<div class="settings-saved">Enregistré.</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/repos-hebdo" class="settings-form">
|
||||
<label class="settings-check">
|
||||
<input type="checkbox" name="repos_hebdo_active" value="1" {% if plages.repos_hebdo_active %}checked{% endif %}>
|
||||
<span>Respecter le repos hebdomadaire de 35h (7 jours glissants)</span>
|
||||
</label>
|
||||
<button class="login-btn" type="submit">Enregistrer</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if plages.astreinte_active %}
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">Ratios des heures d'astreinte</span>
|
||||
|
||||
234
app/tests/test_repos_hebdo.py
Normal file
234
app/tests/test_repos_hebdo.py
Normal file
@ -0,0 +1,234 @@
|
||||
"""Repos hebdomadaire de 35h (24h + 11h consécutives, une fois par 7 jours
|
||||
glissants) : préférence personnelle désactivée par défaut. Un week-end
|
||||
d'astreinte fragmenté par des interventions peut ne contenir aucun trou de
|
||||
35h — le retour est alors repoussé à dernière activité + 35h, et toute nouvelle
|
||||
perturbation avant cette échéance redémarre le décompte."""
|
||||
import sys
|
||||
|
||||
import models # noqa: F401 (DATA_DIR temporaire posé par conftest)
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
from calcul import (
|
||||
DEFAULT_PLAGES, retour_repos, retour_repos_hebdo, retour_txt, compute_week,
|
||||
)
|
||||
import main
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(main.app)
|
||||
|
||||
HEURES_JOUR_MIN = 468 # 7h48
|
||||
PLAGES_OFF = dict(DEFAULT_PLAGES)
|
||||
PLAGES_ON = {**DEFAULT_PLAGES, "repos_hebdo_active": True}
|
||||
|
||||
|
||||
def _p(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None,
|
||||
astreinte_events=None):
|
||||
return {
|
||||
"date": date,
|
||||
"matin_entree": matin_entree, "matin_sortie": matin_sortie,
|
||||
"aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie,
|
||||
"astreinte_events": astreinte_events,
|
||||
}
|
||||
|
||||
|
||||
def _semaine_ouvree(premier_lundi: str) -> list[dict]:
|
||||
"""Lundi→vendredi d'une semaine sans astreinte, journées complètes."""
|
||||
from datetime import date, timedelta
|
||||
d0 = date.fromisoformat(premier_lundi)
|
||||
return [
|
||||
_p((d0 + timedelta(days=i)).isoformat(), "09:00", "12:24", "13:30", "17:54")
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
|
||||
# Semaine de référence : lundi 2026-07-13 (semaine ISO 29), week-end précédent
|
||||
# sam 11 / dim 12 juillet, aujourd'hui figé au vendredi 17/07 10:30.
|
||||
WE_ALEATOIRE = ["2026-07-11", "2026-07-12"]
|
||||
|
||||
|
||||
def _we_fragmente(event_sam, event_dim, extra=None):
|
||||
p = {r["date"]: r for r in _semaine_ouvree("2026-07-06")}
|
||||
p["2026-07-11"] = _p("2026-07-11", astreinte_events=[event_sam])
|
||||
p["2026-07-12"] = _p("2026-07-12", astreinte_events=[event_dim])
|
||||
p.update(extra or {})
|
||||
return p
|
||||
|
||||
|
||||
# ── retour_repos_hebdo : fenêtre glissante de 7 jours ─────────────────────────
|
||||
|
||||
def test_fenetre_vide_sans_contrainte(freeze_today):
|
||||
assert retour_repos_hebdo("2026-07-13", {}, PLAGES_ON, WE_ALEATOIRE) is None
|
||||
|
||||
|
||||
def test_weekend_astreinte_fragmente_repousse_le_lundi(freeze_today):
|
||||
"""Sam 10:00→11:00 et dim 10:00→11:00 : trous de 16h, 23h puis final — aucun
|
||||
35h dans la fenêtre → retour = dim 11:00 + 35h = lundi 22:00."""
|
||||
p = _we_fragmente({"debut": "10:00", "fin": "11:00"}, {"debut": "10:00", "fin": "11:00"})
|
||||
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 22 * 60
|
||||
assert retour_repos("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 22 * 60
|
||||
|
||||
|
||||
def test_desactivee_le_weekend_fragmente_ne_contraint_rien(freeze_today):
|
||||
p = _we_fragmente({"debut": "10:00", "fin": "11:00"}, {"debut": "10:00", "fin": "11:00"})
|
||||
assert retour_repos_hebdo("2026-07-13", p, PLAGES_OFF, WE_ALEATOIRE) is None
|
||||
assert retour_repos("2026-07-13", p, PLAGES_OFF, WE_ALEATOIRE) is None
|
||||
|
||||
|
||||
def test_trou_de_35h_entre_interventions_valide_le_repos(freeze_today):
|
||||
"""Sam 08:00→09:00, dim 20:00→21:00 : le trou sam 09:00 → dim 20:00 fait
|
||||
exactement 35h — repos pris, aucune contrainte (alors que la dernière
|
||||
activité + 35h tomberait mardi 08:00)."""
|
||||
p = _we_fragmente({"debut": "08:00", "fin": "09:00"}, {"debut": "20:00", "fin": "21:00"})
|
||||
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) is None
|
||||
|
||||
|
||||
def test_repos_fini_avant_ouverture_sans_contrainte(freeze_today):
|
||||
"""Sam 06:00→07:00 seulement : trou insuffisant (12h depuis vendredi), mais
|
||||
sam 07:00 + 35h = dimanche 18:00 — le repos s'achève avant l'ouverture du
|
||||
lundi → aucune contrainte."""
|
||||
p = _we_fragmente({"debut": "06:00", "fin": "07:00"}, None)
|
||||
p["2026-07-12"] = _p("2026-07-12")
|
||||
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) is None
|
||||
|
||||
|
||||
def test_repos_qui_deborde_sur_le_lendemain(freeze_today):
|
||||
"""Sam 08:00→09:00, dim 17:00→18:00 : trou de 32h, retour = dim 18:00 + 35h
|
||||
= mardi 05:00 → lundi entièrement couvert (retour ≥ 1440), mardi libre dès
|
||||
l'ouverture (05:00 < 09:00)."""
|
||||
p = _we_fragmente({"debut": "08:00", "fin": "09:00"}, {"debut": "17:00", "fin": "18:00"})
|
||||
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 24 * 60 + 5 * 60
|
||||
assert retour_repos_hebdo("2026-07-14", p, PLAGES_ON, WE_ALEATOIRE) is None
|
||||
|
||||
|
||||
def test_nouvelle_intervention_pendant_le_repos_redemarre_35h(freeze_today):
|
||||
"""Week-end fragmenté (retour lundi 22:00) + intervention lundi matin
|
||||
07:00→08:00 sur le jour qualifié (veille dim AS) : le décompte repart de
|
||||
lundi 08:00 → retour mardi 19:00. Le retour combine 11h (19:00 le lundi)
|
||||
et 35h (mardi 19:00) — le plus tardif gagne."""
|
||||
p = _we_fragmente({"debut": "10:00", "fin": "11:00"}, {"debut": "10:00", "fin": "11:00"},
|
||||
extra={"2026-07-13": _p("2026-07-13", astreinte_events=[{"debut": "07:00", "fin": "08:00"}])})
|
||||
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 24 * 60 + 19 * 60
|
||||
assert retour_repos("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 24 * 60 + 19 * 60
|
||||
|
||||
|
||||
def test_semaine_normale_avec_option_active_sans_contrainte(freeze_today):
|
||||
"""Lundi→vendredi pointés, week-ends sans activité : chaque fenêtre de 7
|
||||
jours contient le week-end libre (trou ≥ 35h) — aucun jour ouvré
|
||||
contraint."""
|
||||
p = {r["date"]: r for r in _semaine_ouvree("2026-07-13")}
|
||||
for d in ("2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"):
|
||||
assert retour_repos_hebdo(d, p, PLAGES_ON, []) is None, d
|
||||
|
||||
|
||||
def test_repos_txt_jour_suivant():
|
||||
assert retour_txt(13 * 60, "2026-07-13") == "13:00"
|
||||
assert retour_txt(24 * 60 + 5 * 60, "2026-07-13") == "Mar 05:00"
|
||||
assert retour_txt(24 * 60 + 19 * 60, "2026-07-13") == "Mar 19:00"
|
||||
|
||||
|
||||
# ── compute_week : contexte de la semaine précédente ──────────────────────────
|
||||
|
||||
def test_compute_week_sans_contexte_le_trou_initial_valide_le_repos(freeze_today):
|
||||
"""Sans les jours précédents (ancien comportement, ex. données absentes),
|
||||
le trou initial de la fenêtre couvre tout : aucune contrainte."""
|
||||
pointages = [_p("2026-07-13"), _p("2026-07-14"), _p("2026-07-15")]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES_ON, WE_ALEATOIRE)
|
||||
lundi = next(j for j in result["jours"] if j["date"] == "2026-07-13")
|
||||
assert lundi["retour_min"] is None
|
||||
|
||||
|
||||
def test_compute_week_contexte_reprise_le_weekend_fragmente(freeze_today):
|
||||
"""Le week-end d'astreinte appartient à la semaine précédente : passé via
|
||||
`contexte`, le lundi est couvert par le repos jusqu'à mardi 05:00."""
|
||||
contexte = _semaine_ouvree("2026-07-06") + [
|
||||
_p("2026-07-11", astreinte_events=[{"debut": "08:00", "fin": "09:00"}]),
|
||||
_p("2026-07-12", astreinte_events=[{"debut": "17:00", "fin": "18:00"}]),
|
||||
]
|
||||
pointages = [
|
||||
_p("2026-07-13"),
|
||||
_p("2026-07-14"),
|
||||
_p("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
|
||||
_p("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
|
||||
]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES_ON, WE_ALEATOIRE,
|
||||
contexte=contexte)
|
||||
lundi = next(j for j in result["jours"] if j["date"] == "2026-07-13")
|
||||
assert lundi["retour_min"] == 24 * 60 + 5 * 60
|
||||
assert lundi["retour"] == "Mar 05:00"
|
||||
assert lundi["couvert_min"] == 6 * 60 # plages 09-12 et 14-17 couvertes
|
||||
assert lundi["travaille_min"] == 6 * 60 # créditées comme effectuées
|
||||
assert lundi["delta_min"] == 6 * 60 - HEURES_JOUR_MIN
|
||||
assert lundi["cible_matin"] is None and lundi["cible_aprem"] is None # rien à planifier
|
||||
# Mardi (14 juillet, férié) : repos fini à 05:00, avant l'ouverture
|
||||
mardi = next(j for j in result["jours"] if j["date"] == "2026-07-14")
|
||||
assert mardi["retour_min"] is None
|
||||
|
||||
|
||||
def test_compute_week_contexte_ignore_si_option_desactivee(freeze_today):
|
||||
contexte = _semaine_ouvree("2026-07-06") + [
|
||||
_p("2026-07-11", astreinte_events=[{"debut": "08:00", "fin": "09:00"}]),
|
||||
_p("2026-07-12", astreinte_events=[{"debut": "17:00", "fin": "18:00"}]),
|
||||
]
|
||||
pointages = [_p("2026-07-13")]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES_OFF, WE_ALEATOIRE,
|
||||
contexte=contexte)
|
||||
lundi = next(j for j in result["jours"] if j["date"] == "2026-07-13")
|
||||
assert lundi["retour_min"] is None
|
||||
|
||||
|
||||
# ── Préférence Réglages ───────────────────────────────────────────────────────
|
||||
|
||||
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 test_bascule_de_la_preference():
|
||||
uid = "u-rh-pref"
|
||||
cookies = _login(uid)
|
||||
assert models.load_plages(uid).get("repos_hebdo_active") is False # désactivée par défaut
|
||||
r = client.post("/settings/repos-hebdo", cookies=cookies,
|
||||
data={"repos_hebdo_active": "1"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert models.load_plages(uid)["repos_hebdo_active"] is True
|
||||
r = client.post("/settings/repos-hebdo", cookies=cookies, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert models.load_plages(uid)["repos_hebdo_active"] is False
|
||||
|
||||
|
||||
def test_carte_visible_et_cochee_dans_reglages():
|
||||
uid = "u-rh-ui"
|
||||
cookies = _login(uid)
|
||||
plages = models.load_plages(uid)
|
||||
plages["repos_hebdo_active"] = True
|
||||
models.save_plages(uid, plages)
|
||||
r = client.get("/settings", cookies=cookies, follow_redirects=False)
|
||||
assert "Repos hebdomadaire de 35h" in r.text
|
||||
assert 'name="repos_hebdo_active" value="1" checked' in r.text
|
||||
|
||||
plages = models.load_plages(uid)
|
||||
plages["astreinte_active"] = False
|
||||
models.save_plages(uid, plages)
|
||||
r = client.get("/settings", cookies=cookies, follow_redirects=False)
|
||||
assert "Repos hebdomadaire de 35h" not in r.text
|
||||
|
||||
|
||||
def test_sauvegarde_plages_conserve_la_preference():
|
||||
uid = "u-rh-merge"
|
||||
cookies = _login(uid)
|
||||
plages = models.load_plages(uid)
|
||||
plages["repos_hebdo_active"] = True
|
||||
models.save_plages(uid, plages)
|
||||
r = client.post("/settings/plages", cookies=cookies, data={
|
||||
"matin_debut": "09:00", "matin_fin": "12:00",
|
||||
"aprem_debut": "14:00", "aprem_fin": "17:00",
|
||||
"pause_dejeuner_fin": "13:30", "arrivee_visee": "", "depart_vise": "",
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303, r.text
|
||||
assert models.load_plages(uid)["repos_hebdo_active"] is True
|
||||
Reference in New Issue
Block a user