From f6fef650a75d84d055b2f58c83e9e47d24e98558 Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Wed, 9 Sep 2026 21:49:19 +0200 Subject: [PATCH] Astreinte : repos hebdomadaire de 35h en option (7 jours glissants) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 24 ++++ app/calcul.py | 137 +++++++++++++++++++- app/main.py | 60 +++++++-- app/templates/_ligne.html | 4 +- app/templates/settings.html | 29 +++++ app/tests/test_repos_hebdo.py | 234 ++++++++++++++++++++++++++++++++++ 6 files changed, 471 insertions(+), 17 deletions(-) create mode 100644 app/tests/test_repos_hebdo.py diff --git a/README.md b/README.md index 11940d5..63ddad9 100644 --- a/README.md +++ b/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), diff --git a/app/calcul.py b/app/calcul.py index f447ec2..becf2f8 100644 --- a/app/calcul.py +++ b/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 diff --git a/app/main.py b/app/main.py index 864354a..8b4d82c 100644 --- a/app/main.py +++ b/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'{"—" if not jour["travaille_min"] else _e(jour["travaille"])}' if jour.get("couvert_min"): - trav_inner += (f'+{_e(jour["couvert_h"])} repos') 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'' + hint = (f'' f'retour ≥ {_e(jour["retour"])}') if jour.get("couvert_min"): hint += (f'' @@ -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, diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index 9b3ef3a..318b7b0 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -52,7 +52,7 @@ {{ j.travaille if j.travaille_min else '—' }} - {% if j.couvert_min %}+{{ j.couvert_h }} repos{% endif %} + {% if j.couvert_min %}+{{ j.couvert_h }} repos{% endif %} @@ -146,7 +146,7 @@ onclick="asToggle(this)"> ▸ {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }} - {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} + {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} {% if j.couvert_min %}+{{ j.couvert_h }} de présence comptées{% endif %} {% if j.forfait_min %}forfait {{ j.forfait_h }}{% endif %} {% if j.astreinte_min %}astreinte : {{ j.astreinte_h }}{% endif %} diff --git a/app/templates/settings.html b/app/templates/settings.html index d206b8b..ebc7ce2 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -72,6 +72,35 @@ +{% if plages.astreinte_active %} +
+ Repos hebdomadaire de 35h +

+ La loi impose, une fois par période de 7 jours glissants, un repos de + 35h consécutives (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 à dernière activité + 35h quand aucune période de + 35h sans activité n'existe dans les 7 derniers jours — et toute nouvelle + intervention avant cette échéance redémarre 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. +

+ + {% if repos_hebdo_saved %} +
Enregistré.
+ {% endif %} + +
+ + +
+
+{% endif %} + {% if plages.astreinte_active %}
Ratios des heures d'astreinte diff --git a/app/tests/test_repos_hebdo.py b/app/tests/test_repos_hebdo.py new file mode 100644 index 0000000..e01e26d --- /dev/null +++ b/app/tests/test_repos_hebdo.py @@ -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