From d760842121a0cab20fb04cda32bfd79dfa4cde8e Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Thu, 10 Sep 2026 17:05:05 +0200 Subject: [PATCH] =?UTF-8?q?Forfait=20d'astreinte=20:=20bonifi=C3=A9=20comm?= =?UTF-8?q?e=20r=C3=A9alis=C3=A9=20en=20journ=C3=A9e=20du=20jour=20AS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le forfait était suivi brut, sans bonification. Considéré réalisé en journée du jour où il est attribué, il reçoit désormais le facteur journée correspondant : ×1 en semaine, ×1,5 le samedi, ×2 le dimanche ou un jour férié (suit les règles de ratios configurées). Toujours hors heures travaillées ; affiché pondéré sur la ligne du jour, dans les totaux du récapitulatif et expliqué dans Réglages. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- app/calcul.py | 42 +++++++++++++++++++++++++++++++- app/main.py | 17 ++++++++++--- app/templates/_ligne.html | 6 ++--- app/templates/_soldes.html | 6 ++--- app/templates/settings.html | 7 ++++-- app/tests/test_astreinte.py | 46 ++++++++++++++++++++++++++++++++---- app/tests/test_ui_refonte.py | 17 +++++++++++++ 7 files changed, 125 insertions(+), 16 deletions(-) diff --git a/app/calcul.py b/app/calcul.py index becf2f8..7b23b79 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -197,6 +197,32 @@ def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]: ] +MIDI_MIN = 12 * 60 # minute représentative de la journée (le forfait AS y est réputé réalisé) + + +def regle_journee_astreinte(date_str: str, facteurs: list[dict] | None = None, + feries_declares: list[str] | set[str] | None = None) -> dict | None: + """Règle de bonification des heures de journée du jour donné : la première + règle dont le type de jour et la fenêtre horaire couvrent midi, minute + représentative de la journée. Le forfait d'astreinte est considéré réalisé + en journée du jour où il est attribué : c'est ce facteur qui le pondère + (semaine ×1, samedi ×1,5, dimanche/férié ×2 avec les règles par défaut). + None si aucune règle ne couvre midi ce jour-là (forfait non bonifié).""" + regles = _regles_astreinte(facteurs) + jour0 = datetime.strptime(date_str, "%Y-%m-%d").date() + feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1) + for s in (feries_declares or []): + try: + feries.add(datetime.strptime(s, "%Y-%m-%d").date()) + except (TypeError, ValueError): + continue + cat = _categorie_jour(jour0, feries) + r = next((r for r in regles if cat in r["jours"] and _dans_fenetre(MIDI_MIN, r["debut"], r["fin"])), None) + if r is None: + return None + return {"nom": r["nom"], "facteur": r["facteur"], "facteur_txt": _facteur_txt(r["facteur"])} + + def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None, fenetre: tuple[int, int] | None = None, feries_declares: list[str] | set[str] | None = None) -> dict: @@ -586,6 +612,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int as_total_min = 0 as_total_pondere_min = 0 as_forfait_min = 0 + as_forfait_pondere_min = 0 as_cats: dict[str, dict] = {} for p in pointages: @@ -603,8 +630,14 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int if jour_astreinte_qualifie(p["date"], astreintes) and events else None) matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages, jour_ferie) forfait = forfait_as_min if p["date"] in astreintes else 0 + # Le forfait d'astreinte est considéré réalisé en journée du jour AS + # déclaré : il bénéficie de la bonification « journée » de ce jour + # (×1 en semaine, ×1,5 le samedi, ×2 le dimanche ou férié par défaut). + regle_journee = regle_journee_astreinte(p["date"], facteurs, feries) if forfait else None + forfait_pondere = int(forfait * (regle_journee or {"facteur": 1.0})["facteur"] + 0.5) if forfait else 0 # Le forfait d'astreinte ne compte jamais dans les heures travaillées : - # il est suivi à part dans le récapitulatif d'astreinte. + # il est suivi à part dans le récapitulatif d'astreinte, pondéré par la + # bonification journée. # # Les heures de présence obligatoire couvertes par le repos de 11h # (matin_ex + aprem_ex) sont comptées comme effectuées : créditées au @@ -662,8 +695,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "total_pondere_h": dec["total_pondere_h"] if dec else "", "forfait_min": forfait, "forfait_h": minutes_to_hhmm(forfait) if forfait else "", + "forfait_pondere_min": forfait_pondere, + "forfait_pondere_h": minutes_to_hhmm(forfait_pondere) if forfait_pondere else "", + "forfait_regle": regle_journee["nom"] if regle_journee else "", + "forfait_facteur_txt": regle_journee["facteur_txt"] if regle_journee else "1", }) as_forfait_min += forfait + as_forfait_pondere_min += forfait_pondere if dec and dec["total_min"] > 0: as_total_min += dec["total_min"] as_total_pondere_min += dec["total_pondere_min"] @@ -875,6 +913,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "total_pondere_h": minutes_to_hhmm(as_total_pondere_min), "total_forfait_min": as_forfait_min, "total_forfait_h": minutes_to_hhmm(as_forfait_min), + "total_forfait_pondere_min": as_forfait_pondere_min, + "total_forfait_pondere_h": minutes_to_hhmm(as_forfait_pondere_min), "categories": as_categories, "jours": as_jours, }, diff --git a/app/main.py b/app/main.py index 29c8e3b..93f58fe 100644 --- a/app/main.py +++ b/app/main.py @@ -35,7 +35,7 @@ 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, retour_repos, retour_txt, decompte_astreinte, - fenetre_astreinte_jour, + fenetre_astreinte_jour, regle_journee_astreinte, ) from stats import compute_all_stats from export import export_semaine_ods, export_annee_ods @@ -370,6 +370,11 @@ def _build_jour_ctx(date, conges, h_jour, user_id): 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 + # Le forfait est considéré réalisé en journée du jour AS déclaré : il reçoit + # la bonification « journée » de ce jour (×1 semaine, ×1,5 samedi, ×2 + # dimanche/férié par défaut) — pondéré, mais toujours hors heures travaillées. + regle_journee = regle_journee_astreinte(date, load_facteurs_astreinte(), feries) if forfait else None + forfait_pondere = int(forfait * (regle_journee or {"facteur": 1.0})["facteur"] + 0.5) if forfait else 0 # Les heures de présence obligatoire couvertes par le repos de 11h sont # comptées comme effectuées : créditées au travaillé, dues complètes (le # forfait d'astreinte, lui, reste hors heures travaillées). Sans heures @@ -395,6 +400,10 @@ def _build_jour_ctx(date, conges, h_jour, user_id): "ferie": jour_ferie, "forfait_min": forfait, "forfait_h": minutes_to_hhmm(forfait) if forfait else "", + "forfait_pondere_min": forfait_pondere, + "forfait_pondere_h": minutes_to_hhmm(forfait_pondere) if forfait_pondere else "", + "forfait_regle": regle_journee["nom"] if regle_journee else "", + "forfait_facteur_txt": regle_journee["facteur_txt"] if regle_journee else "1", "du_full_min": du_full, "matin_excl_min": matin_ex, "aprem_excl_min": aprem_ex, "couvert_min": couvert, @@ -573,8 +582,10 @@ def _as_block_html(date, jour): hint += (f'' f'+{_e(jour["couvert_h"])} de présence comptées') if jour.get("forfait_min"): - hint += (f'' - f'forfait {_e(jour["forfait_h"])}') + f_txt = jour.get("forfait_facteur_txt") or "1" + f_regle = f" ({_e(jour['forfait_regle'])})" if jour.get("forfait_regle") else "" + hint += (f'' + f'forfait {_e(jour["forfait_h"])} → {_e(jour["forfait_pondere_h"])} pondérées') total = "" if jour.get("astreinte_min"): total = (f'' diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index 318b7b0..a8935d9 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -148,7 +148,7 @@ {% 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.forfait_min %}forfait {{ j.forfait_h }}{% if j.forfait_pondere_min != j.forfait_min %} → {{ j.forfait_pondere_h }} pondérées{% endif %}{% endif %} {% if j.astreinte_min %}astreinte : {{ j.astreinte_h }}{% endif %}
@@ -165,7 +165,7 @@ data-date="{{ j.date }}" maxlength="200" placeholder="description" value="{{ ev.desc or '' }}"> + onclick="asDelEvent(this)">×
{% else %} @@ -178,7 +178,7 @@ + onclick="asDelEvent(this)">× {% endfor %} diff --git a/app/templates/_soldes.html b/app/templates/_soldes.html index af06178..d31297c 100644 --- a/app/templates/_soldes.html +++ b/app/templates/_soldes.html @@ -91,7 +91,7 @@
{{ j.jour }} {{ j.date[8:] }}/{{ j.date[5:7] }} - {% if j.forfait_min %}forfait {{ j.forfait_h }}{% endif %} + {% if j.forfait_min %}forfait {{ j.forfait_h }}{% if j.forfait_pondere_min != j.forfait_min %} → {{ j.forfait_pondere_h }} pondérées{% endif %}{% endif %} {% if j.total_min %}astreinte {{ j.total_h }} → {{ j.total_pondere_h }} pondérées{% endif %}
{% for ev in j.events %} @@ -111,8 +111,8 @@ {% endfor %}
{% for c in result.astreinte.categories %}{{ c.nom }} : {{ c.min_h }} ×{{ c.facteur_txt }} = {{ c.pondere_h }}{% endfor %} - {% if result.astreinte.total_forfait_min %}forfaits {{ result.astreinte.total_forfait_h }} (hors heures travaillées){% endif %} - total {{ result.astreinte.total_h }} → {{ result.astreinte.total_pondere_h }} pondérées + {% if result.astreinte.total_forfait_min %}forfaits {{ result.astreinte.total_forfait_h }} → {{ result.astreinte.total_forfait_pondere_h }} pondérés (hors heures travaillées){% endif %} + total {{ result.astreinte.total_h }} → {{ result.astreinte.total_pondere_h }} pondérées{% if result.astreinte.total_forfait_pondere_min %} (+ {{ result.astreinte.total_forfait_pondere_h }} de forfaits pondérés){% endif %}
{% endif %} diff --git a/app/templates/settings.html b/app/templates/settings.html index ebc7ce2..54c3f98 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -182,8 +182,11 @@ function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); }

Durée forfaitaire attribuée à chaque jour d'astreinte déclaré (bouton AS), en plus des interventions saisies. Elle ne compte jamais dans les heures - travaillées : elle est suivie à part dans le récapitulatif d'astreinte de la - semaine ; le lendemain de l'astreinte n'est pas concerné. + travaillées : suivie à part dans le récapitulatif d'astreinte, elle est + considérée réalisée en journée du jour AS et bénéficie donc de la + bonification « journée » correspondante (×1 en semaine, ×1,5 le samedi, + ×2 le dimanche ou un jour férié) ; le lendemain de l'astreinte n'est pas + concerné. Vide = pas de forfait.

diff --git a/app/tests/test_astreinte.py b/app/tests/test_astreinte.py index eb39c4e..6894770 100644 --- a/app/tests/test_astreinte.py +++ b/app/tests/test_astreinte.py @@ -2,7 +2,8 @@ from calcul import ( compute_week, decompte_astreinte, heures_dues, heures_travaillees, hhmm_to_minutes, jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, fenetre_astreinte_jour, - retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN, ASTREINTE_DEBUT_MIN, + retour_apres_repos, regle_journee_astreinte, DEFAULT_PLAGES, PAS_ASTREINTE_MIN, + ASTREINTE_DEBUT_MIN, ) import models @@ -620,7 +621,8 @@ def test_compute_week_astreinte_samedi_dimanche(freeze_today): def test_compute_week_forfait_hors_heures_travaillees(freeze_today): """Forfait 1h00 réglé : attribué au jour AS déclaré uniquement (pas du lendemain), mais jamais compté dans les heures travaillées — seulement - suivi dans le récapitulatif d'astreinte.""" + suivi dans le récapitulatif d'astreinte, pondéré par la bonification + journée du jour (2026-07-14 est férié : ×2).""" plages = {**PLAGES, "astreinte_forfait": "01:00"} pointages = [ _pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"), @@ -635,13 +637,49 @@ def test_compute_week_forfait_hors_heures_travaillees(freeze_today): assert result["jours"][0]["forfait_min"] == 0 # jour sans astreinte assert result["total_travaille"] == "15h36" # 2 × 7h48, forfait exclu # récapitulatif d'astreinte : le jour AS apparaît avec son forfait même sans - # intervention ; le forfait est rappelé mais hors compteurs pondérés + # intervention ; le forfait est bonifié « journée » du jour (férié → ×2) ast = result["astreinte"] assert ast["actif"] is True assert ast["total_min"] == 0 assert ast["total_forfait_min"] == 60 assert ast["total_forfait_h"] == "1h00" - assert [(j["date"], j["forfait_min"], j["events"]) for j in ast["jours"]] == [("2026-07-14", 60, [])] + assert ast["total_forfait_pondere_min"] == 120 + assert ast["total_forfait_pondere_h"] == "2h00" + [(jour_as)] = ast["jours"] + assert (jour_as["date"], jour_as["forfait_min"], jour_as["events"]) == ("2026-07-14", 60, []) + assert (jour_as["forfait_pondere_min"], jour_as["forfait_regle"], jour_as["forfait_facteur_txt"]) == (120, "Dimanche / férié", "2") + + +def test_forfait_bonifie_selon_la_journee_du_jour(freeze_today): + """Le forfait est considéré réalisé en journée : il bénéficie de la + bonification « journée » du jour où il est attribué — ×1 en semaine, ×1,5 + le samedi, ×2 le dimanche ou un férié (règles par défaut).""" + plages = {**PLAGES, "astreinte_forfait": "01:00"} + pointages = [_pointage(d) for d in + ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17", + "2026-07-18", "2026-07-19"]] + result = compute_week(pointages, [], HEURES_JOUR_MIN, plages, + ["2026-07-15", "2026-07-18", "2026-07-19"]) + ast = result["astreinte"] + details = {j["date"]: (j["forfait_min"], j["forfait_pondere_min"], j["forfait_regle"]) + for j in ast["jours"]} + assert details["2026-07-15"] == (60, 60, "Journée") # mercredi : ×1 + assert details["2026-07-18"] == (60, 90, "Samedi") # samedi : ×1,5 + assert details["2026-07-19"] == (60, 120, "Dimanche / férié") # dimanche : ×2 + assert ast["total_forfait_min"] == 180 + assert ast["total_forfait_pondere_min"] == 270 + assert ast["total_forfait_pondere_h"] == "4h30" + + +def test_regle_journee_astreinte_suit_les_regles_configurees(): + """La bonification journée du forfait suit les règles configurées : première + règle couvrant midi pour le type du jour.""" + regles = [{"nom": "Journée spéciale", "jours": ["semaine"], "debut": "06:00", "fin": "22:00", "facteur": 1.25}] + r = regle_journee_astreinte("2026-07-15", regles) + assert r is not None + assert (r["nom"], r["facteur"], r["facteur_txt"]) == ("Journée spéciale", 1.25, "1,25") + # Aucune règle ne couvre midi ce jour-là : pas de bonification + assert regle_journee_astreinte("2026-07-15", [{"nom": "Nuit", "jours": ["semaine"], "debut": "22:00", "fin": "06:00", "facteur": 1.5}]) is None def test_compute_week_sans_forfait_comportement_inchange(freeze_today): diff --git a/app/tests/test_ui_refonte.py b/app/tests/test_ui_refonte.py index 36b93bc..d44cc07 100644 --- a/app/tests/test_ui_refonte.py +++ b/app/tests/test_ui_refonte.py @@ -96,3 +96,20 @@ def test_retour_decale_affiche_le_credit_de_presence(monkeypatch): assert "+2h00 repos" in r.text assert "matin 09:00→11:00" in r.text # détail des plages couvertes assert "de présence comptées" in r.text # rappel dans le bloc astreinte + + +def test_forfait_bonifie_journee_affiche_dans_le_recap(monkeypatch): + """Forfait 1h00 réglé + AS le samedi 18/07 : le récapitulatif montre le + forfait pondéré par la bonification journée du samedi (×1,5 → 1h30).""" + monkeypatch.setattr(main, "datetime", _FrozenDatetime) + cookies = _login("u-ui-forfait") + r = client.post("/settings/astreinte-forfait", cookies=cookies, + data={"astreinte_forfait": "01:00"}, follow_redirects=False) + assert r.status_code == 303, r.text + client.post("/astreinte/2026-07-18/toggle", cookies=cookies, follow_redirects=False) + + r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False) + assert r.status_code == 200, r.text + assert "forfait 1h00 → 1h30 pondérées" in r.text # ligne du jour (bloc AS) + assert "forfaits 1h00 → 1h30 pondérés" in r.text # totaux du récapitulatif + assert "considéré réalisé en journée" in r.text # explication au survol