Forfait d'astreinte : bonifié comme réalisé en journée du jour AS
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
This commit is contained in:
@ -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,
|
||||
},
|
||||
|
||||
17
app/main.py
17
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'<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é">'
|
||||
f'+{_e(jour["couvert_h"])} de présence comptées</span>')
|
||||
if jour.get("forfait_min"):
|
||||
hint += (f'<span class="as-hint" title="Forfait du jour AS déclaré (réglé dans Réglages) : suivi dans le récapitulatif d'astreinte, hors heures travaillées">'
|
||||
f'forfait {_e(jour["forfait_h"])}</span>')
|
||||
f_txt = jour.get("forfait_facteur_txt") or "1"
|
||||
f_regle = f" ({_e(jour['forfait_regle'])})" if jour.get("forfait_regle") else ""
|
||||
hint += (f'<span class="as-hint" title="Forfait du jour AS déclaré (réglé dans Réglages), considéré réalisé en journée du jour : pondéré ×{f_txt}{f_regle} — suivi dans le récapitulatif d'astreinte, hors heures travaillées">'
|
||||
f'forfait {_e(jour["forfait_h"])} → {_e(jour["forfait_pondere_h"])} pondérées</span>')
|
||||
total = ""
|
||||
if jour.get("astreinte_min"):
|
||||
total = (f'<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) — compteur séparé, hors heures de semaine">'
|
||||
|
||||
@ -148,7 +148,7 @@
|
||||
</button>
|
||||
{% 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.forfait_min %}<span class="as-hint" title="Forfait du jour AS déclaré (Réglages), considéré réalisé en journée du jour : pondéré ×{{ j.forfait_facteur_txt }}{% if j.forfait_regle %} ({{ j.forfait_regle }}){% endif %} — suivi dans le récapitulatif d'astreinte, hors heures travaillées">forfait {{ j.forfait_h }}{% if j.forfait_pondere_min != j.forfait_min %} → {{ j.forfait_pondere_h }} pondérées{% endif %}</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 %}
|
||||
</div>
|
||||
<div class="as-body">
|
||||
@ -165,7 +165,7 @@
|
||||
data-date="{{ j.date }}" maxlength="200" placeholder="description"
|
||||
value="{{ ev.desc or '' }}">
|
||||
<button type="button" class="as-del" title="Supprimer"
|
||||
onclick="this.closest('.as-event').remove()">×</button>
|
||||
onclick="asDelEvent(this)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
@ -178,7 +178,7 @@
|
||||
<input type="text" class="as-desc" name="astreinte_desc"
|
||||
data-date="{{ j.date }}" maxlength="200" placeholder="description" value="">
|
||||
<button type="button" class="as-del" title="Supprimer"
|
||||
onclick="this.closest('.as-event').remove()">×</button>
|
||||
onclick="asDelEvent(this)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@ -91,7 +91,7 @@
|
||||
<div class="as-jour">
|
||||
<div class="as-jour-head">
|
||||
<span class="as-jour-date">{{ j.jour }} {{ j.date[8:] }}/{{ j.date[5:7] }}</span>
|
||||
{% if j.forfait_min %}<span class="as-forfait" title="Forfait du jour AS déclaré (réglé dans Réglages) : suivi dans le récapitulatif d'astreinte, jamais compté dans les heures travaillées ni dans les compteurs pondérés">forfait {{ j.forfait_h }}</span>{% endif %}
|
||||
{% if j.forfait_min %}<span class="as-forfait" title="Forfait du jour AS déclaré (réglé dans Réglages), considéré réalisé en journée du jour : pondéré par le facteur journée correspondant ({{ j.forfait_regle }} ×{{ j.forfait_facteur_txt }}), jamais compté dans les heures travaillées">forfait {{ j.forfait_h }}{% if j.forfait_pondere_min != j.forfait_min %} → {{ j.forfait_pondere_h }} pondérées{% endif %}</span>{% endif %}
|
||||
{% if j.total_min %}<span class="as-jour-total">astreinte {{ j.total_h }} → {{ j.total_pondere_h }} pondérées</span>{% endif %}
|
||||
</div>
|
||||
{% for ev in j.events %}
|
||||
@ -111,8 +111,8 @@
|
||||
{% endfor %}
|
||||
<div class="as-totaux">
|
||||
{% for c in result.astreinte.categories %}<span class="as-cat">{{ c.nom }} : {{ c.min_h }} ×{{ c.facteur_txt }} = {{ c.pondere_h }}</span>{% endfor %}
|
||||
{% if result.astreinte.total_forfait_min %}<span class="as-forfait" title="Forfaits des jours AS déclarés : suivis à part, jamais comptés dans les heures travaillées">forfaits {{ result.astreinte.total_forfait_h }} <em>(hors heures travaillées)</em></span>{% endif %}
|
||||
<span class="as-grand">total {{ result.astreinte.total_h }} → {{ result.astreinte.total_pondere_h }} pondérées</span>
|
||||
{% if result.astreinte.total_forfait_min %}<span class="as-forfait" title="Forfaits des jours AS déclarés, considérés réalisés en journée de leur jour : pondérés par le facteur journée (semaine ×1, sam ×1,5, dim/férié ×2 par défaut) — jamais comptés dans les heures travaillées">forfaits {{ result.astreinte.total_forfait_h }} → {{ result.astreinte.total_forfait_pondere_h }} pondérés <em>(hors heures travaillées)</em></span>{% endif %}
|
||||
<span class="as-grand">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 %}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -182,8 +182,11 @@ function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); }
|
||||
<p class="settings-p">
|
||||
Durée forfaitaire attribuée à chaque jour d'astreinte déclaré (bouton <b>AS</b>),
|
||||
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.
|
||||
</p>
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user