Astreinte : durée réelle arrondie au quart d'heure supérieur

Le décompte ne compte plus les quarts d'horloge (:00, :15, :30, :45)
entamés mais la durée réelle de chaque intervention arrondie au quart
d'heure supérieur (2, 5 ou 12 min → 15 ; 22 ou 28 → 30), même si
l'arrondi ne tombe pas juste sur l'horloge. Les minutes communes à deux
interventions restent comptées une seule fois, le repos de 11h s'appuie
sur la durée arrondie, et l'interface/README expliquent la nouvelle
méthode.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-09 20:12:39 +02:00
parent 5506f1ba39
commit 791b48b320
7 changed files with 142 additions and 126 deletions

View File

@ -13,12 +13,12 @@ DEFAULT_PLAGES = {
}
REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau
PAS_ASTREINTE_MIN = 15 # pas du décompte astreinte : toute tranche entamée compte en totalité
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
# Règles de comptage des heures d'astreinte : chaque tranche de 15 min est
# Règles de comptage des heures d'astreinte : chaque minute comptée est
# attribuée à la première règle dont le type de jour et la fenêtre horaire la
# couvrent, puis pondérée par son facteur. "jours" parmi : semaine, sam, dim, ferie.
# couvre, puis pondérée par son facteur. "jours" parmi : semaine, sam, dim, ferie.
DEFAULT_FACTEURS_ASTREINTE = [
{"nom": "Nuit", "jours": ["semaine", "sam", "dim", "ferie"], "debut": "20:00", "fin": "08:00", "facteur": 1.5},
{"nom": "Journée", "jours": ["semaine"], "debut": "08:00", "fin": "20:00", "facteur": 1.0},
@ -183,17 +183,20 @@ def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]:
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:
"""Décompte des heures réalisées en astreinte pour un jour, par pas de 15 min :
toute tranche entamée est comptée en totalité. Chaque tranche est attribuée à la
première règle (type de jour × fenêtre horaire) qui la couvre, puis pondérée par
son facteur. Les événements vivent sur la timeline réelle : fin < début =
intervention à cheval sur minuit, dont la partie après minuit compte sur le jour
suivant. Les tranches couvertes par plusieurs événements ne sont comptées
qu'une fois, attribuées au premier événement qui les entame (ordre
chronologique) : les compteurs des événements somment au total du jour.
"""Décompte des heures réalisées en astreinte pour un jour : 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), même si l'arrondi ne tombe
pas juste sur un quart d'heure de l'horloge (:00, :15, :30, :45). Chaque
minute comptée est attribuée à la première règle (type de jour × fenêtre
horaire) qui la couvre, puis pondérée par son facteur. Les événements vivent
sur la timeline réelle : fin < début = intervention à cheval sur minuit,
dont la partie après minuit compte sur le jour suivant. Les minutes réelles
couvertes par plusieurs événements ne sont comptées qu'une fois, attribuées
au premier événement qui les couvre (ordre chronologique) : les compteurs
des événements somment au total du jour.
`fenetre` (bornes en minutes sur la timeline du jour, cf. fenetre_astreinte_jour)
borne la période d'astreinte : seules les tranches à l'intérieur comptent, les
borne la période d'astreinte : seules les minutes à l'intérieur comptent, les
événements ou fragments hors fenêtre sont ignorés. None = tout compter.
`feries_declares` (dates « YYYY-MM-DD » marquées à la main) complète les jours
fériés légaux calculés pour l'attribution des règles de type « ferie ».
@ -203,8 +206,8 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
"""
regles = _regles_astreinte(facteurs)
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
# Fériés légaux (calculés) + fériés marqués à la main : les deux qualifient les
# quarts pour les règles de type « ferie ».
# Fériés légaux (calculés) + fériés marqués à la main : les deux qualifient
# les minutes pour les règles de type « ferie ».
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
for s in (feries_declares or []):
try:
@ -212,17 +215,16 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
except (TypeError, ValueError):
continue
def _regle_du_quart(qm: int) -> dict | None:
def _regle_de_la_minute(qm: int) -> dict | None:
cat_j = _categorie_jour(_EPOCH_DATE + timedelta(days=qm // 1440), feries)
minute = qm % 1440
return next((r for r in regles if cat_j in r["jours"] and _dans_fenetre(minute, r["debut"], r["fin"])), None)
# Attribution de chaque tranche (minute absolue alignée sur le pas) à sa règle ;
# la première affectation gagne si des événements se chevauchent.
quart_regles: dict[int, dict | None] = {}
vus: set[int] = set()
events_out = []
# Événements triés par début : un quart entamé par plusieurs interventions qui
# se suivent ou se chevauchent n'est compté qu'une fois, attribué à la première.
# Événements triés par début : une minute réelle couverte par plusieurs
# interventions qui se suivent ou se chevauchent n'est comptée qu'une fois,
# attribuée à la première.
parses = []
for e in events:
deb, fin = e.get("debut"), e.get("fin")
@ -238,58 +240,57 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
deb_min, duree = debut_c, fin_c - debut_c
parses.append((e, deb, fin, deb_min, duree))
parses.sort(key=lambda t: (t[3], t[3] + t[4]))
vus: set[int] = set()
for e, deb, fin, deb_min, duree in parses:
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
quarts = [qm for qm in range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
a0 + duree, PAS_ASTREINTE_MIN)
if qm not in vus]
vus.update(quarts)
for qm in quarts:
quart_regles.setdefault(qm, _regle_du_quart(qm))
nouvelles = [qm for qm in range(a0, a0 + duree) if qm not in vus]
vus.update(range(a0, a0 + duree))
ev_reel = len(nouvelles)
# Durée réelle arrondie au quart d'heure supérieur : 2, 5 ou 12 min
# réelles → 15 ; 22 ou 28 → 30 — même hors des quarts d'horloge.
ev_min = -(-ev_reel // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
# La durée comptée court depuis la première minute non déjà couverte ;
# chaque minute comptée (réelle ou d'arrondi) rejoint sa règle.
arrondi0 = nouvelles[0] if nouvelles else a0
ev_cats: dict[str, dict] = {}
for qm in quarts:
regle = quart_regles.get(qm)
for qm in range(arrondi0, arrondi0 + ev_min):
regle = _regle_de_la_minute(qm)
nom = regle["nom"] if regle else "Autre"
facteur = regle["facteur"] if regle else 1.0
c = ev_cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "tranches": 0})
c["tranches"] += 1
ev_min = len(quarts) * PAS_ASTREINTE_MIN
c = ev_cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "min": 0})
c["min"] += 1
ev_details = [
{**c, "min": c["tranches"] * PAS_ASTREINTE_MIN,
"min_h": minutes_to_hhmm(c["tranches"] * PAS_ASTREINTE_MIN),
"pondere_min": int(c["tranches"] * PAS_ASTREINTE_MIN * c["facteur"] + 0.5)}
{**c, "min_h": minutes_to_hhmm(c["min"]),
"pondere_min": int(c["min"] * c["facteur"] + 0.5)}
for c in ev_cats.values()
]
ev_pondere = sum(d["pondere_min"] for d in ev_details)
events_out.append({
"debut": deb, "fin": fin, "desc": e.get("desc") or "",
"tranches_debut": minutes_to_time(quarts[0] % 1440) if quarts else "",
"tranches_fin": minutes_to_time((quarts[-1] + PAS_ASTREINTE_MIN) % 1440) if quarts else "",
"tranches": len(quarts), "min": ev_min, "min_h": minutes_to_hhmm(ev_min),
"reel_min": ev_reel, "reel_h": minutes_to_hhmm(ev_reel),
"arrondi_debut": minutes_to_time(arrondi0 % 1440) if ev_min else "",
"arrondi_fin": minutes_to_time((arrondi0 + ev_min) % 1440) if ev_min else "",
"tranches": ev_min // PAS_ASTREINTE_MIN, "min": ev_min, "min_h": minutes_to_hhmm(ev_min),
"pondere_min": ev_pondere, "pondere_h": minutes_to_hhmm(ev_pondere),
"details": ev_details,
})
cats: dict[str, dict] = {}
for qm, regle in quart_regles.items():
nom = regle["nom"] if regle else "Autre"
facteur = regle["facteur"] if regle else 1.0
c = cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "tranches": 0})
c["tranches"] += 1
for ev in events_out:
for d in ev["details"]:
c = cats.setdefault(d["nom"], {"nom": d["nom"], "facteur": d["facteur"], "facteur_txt": d["facteur_txt"], "min": 0})
c["min"] += d["min"]
categories = []
for nom, c in cats.items():
cmin = c["tranches"] * PAS_ASTREINTE_MIN
categories.append({**c, "min": cmin, "min_h": minutes_to_hhmm(cmin),
"pondere_min": int(cmin * c["facteur"] + 0.5),
"pondere_h": minutes_to_hhmm(int(cmin * c["facteur"] + 0.5))})
categories.append({**c, "min_h": minutes_to_hhmm(c["min"]),
"pondere_min": int(c["min"] * c["facteur"] + 0.5),
"pondere_h": minutes_to_hhmm(int(c["min"] * c["facteur"] + 0.5))})
ordre = [r["nom"] for r in regles] + ["Autre"]
categories.sort(key=lambda c: ordre.index(c["nom"]) if c["nom"] in ordre else len(ordre))
total_min = len(quart_regles) * PAS_ASTREINTE_MIN
total_min = sum(e["min"] for e in events_out)
total_pondere = sum(c["pondere_min"] for c in categories)
return {
"tranches": len(quart_regles),
"tranches": total_min // PAS_ASTREINTE_MIN,
"total_min": total_min,
"total_h": minutes_to_hhmm(total_min),
"total_pondere_min": total_pondere,
@ -309,11 +310,12 @@ dernière activité d'astreinte.
termine l'activité. L'astreinte couvre 18:00 → 18:00 le lendemain — sauf
samedi, dimanche et jour férié, où la journée entière compte (0h00 → 0h00) :
hors ces jours, une intervention terminée avant 18:00 sur le jour déclaré
n'est pas de l'astreinte et ne déclenche pas de repos. Comme pour le décompte, tout quart d'heure
entamé est travaillé : la fin est arrondie au quart d'heure supérieur avant
d'ajouter le repos. Le retour s'applique au jour où se termine le repos :
fin 23:30 → retour 10:30 le lendemain ; fin 23:37 (comptée 23:45) → retour
10:45 ; fin 02:00 → retour 13:00 le jour même.
n'est pas de l'astreinte et ne déclenche pas de repos. Comme pour le décompte, la durée
réelle de l'intervention est arrondie au quart d'heure supérieur : la fin
comptée est le début + la durée arrondie, avant d'ajouter le repos. Le retour
s'applique au jour où se termine le repos : fin 23:30 → retour 10:30 le
lendemain ; 21:00 → 23:37 (durée comptée 2h45, fin 23:45) → retour 10:45 ;
fin 02:00 → retour 13:00 le jour même.
Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour
au-delà de l'ouverture."""
astreintes = set(astreintes or [])
@ -327,17 +329,19 @@ dernière activité d'astreinte.
if deb and fin:
yield hhmm_to_minutes(deb), hhmm_to_minutes(fin)
def _fin_comptee(fin: int) -> int:
"""Fin d'activité effectivement travaillée : toute tranche de 15 min
entamée compte, la fin est arrondie au quart d'heure supérieur."""
return -(-fin // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
def _fin_comptee(deb: int, fin: int) -> int:
"""Fin d'activité effectivement comptée (sur la timeline du jour du
début) : durée réelle arrondie au quart d'heure supérieur (2, 5 ou
12 min → 15 ; 22 ou 28 → 30), même hors des quarts d'horloge."""
duree = fin - deb if fin >= deb else 24 * 60 - deb + fin
return deb + -(-duree // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
# Événements du jour, fin matinale : le matin du jour n'est de l'astreinte
# que si la veille était déclarée AS (fenêtre 18:00→18:00) ; une fin matinale
# y termine une intervention nocturne et le repos s'achève aujourd'hui.
if veille in astreintes:
for deb, fin in _fins(date_str):
fin_c = _fin_comptee(fin)
fin_c = _fin_comptee(deb, fin)
if fin >= deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin_c + REPOS_QUOTIDIEN_MIN)
# Événements de la veille déclarée AS : fin de soirée (après 18:00, dans la
@ -346,11 +350,11 @@ dernière activité d'astreinte.
# aujourd'hui.
if veille in astreintes:
for deb, fin in _fins(veille):
fin_c = _fin_comptee(fin)
fin_c = _fin_comptee(deb, fin)
if fin >= deb and fin_c > ASTREINTE_DEBUT_MIN and fin_c + REPOS_QUOTIDIEN_MIN >= 24 * 60:
cands.append(fin_c + REPOS_QUOTIDIEN_MIN - 24 * 60)
if fin < deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin_c + REPOS_QUOTIDIEN_MIN)
if fin < deb and fin_c - 24 * 60 + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin_c - 24 * 60 + REPOS_QUOTIDIEN_MIN)
if not cands:
return None
best = max(cands)
@ -451,8 +455,9 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
# 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)
# Décompte astreinte du jour : événements multiples, pas de 15 min entamées ;
# un férié marqué à la main qualifie les quarts pour les règles « ferie »
# 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 »
events = p.get("astreinte_events") or []
dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes, feries), feries)
if jour_astreinte_qualifie(p["date"], astreintes) and events else None)
@ -511,8 +516,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
as_total_min += dec["total_min"]
as_total_pondere_min += dec["total_pondere_min"]
for c in dec["categories"]:
fc = as_cats.setdefault(c["nom"], {"nom": c["nom"], "facteur": c["facteur"], "tranches": 0, "min": 0, "pondere_min": 0})
fc["tranches"] += c["tranches"]
fc = as_cats.setdefault(c["nom"], {"nom": c["nom"], "facteur": c["facteur"], "min": 0, "pondere_min": 0})
fc["min"] += c["min"]
fc["pondere_min"] += c["pondere_min"]
@ -525,7 +529,6 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"nom": nom,
"facteur": c["facteur"],
"facteur_txt": _facteur_txt(c["facteur"]),
"tranches": c["tranches"],
"min": c["min"],
"min_h": minutes_to_hhmm(c["min"]),
"pondere_min": c["pondere_min"],

View File

@ -489,14 +489,14 @@ 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é, fin arrondie au quart d\'heure entamé">'
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">'
f'retour ≥ {_e(jour["retour"])}</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&#39;astreinte, hors heures travaillées">'
f'forfait {_e(jour["forfait_h"])}</span>')
total = ""
if jour.get("astreinte_min"):
total = (f'<span class="as-total" title="Décompte par quarts d&#39;heure de l&#39;horloge entamés (:00, :15, :30, :45), chacun compté 15 min entières — compteur séparé, hors heures de semaine">'
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">'
f'astreinte : {_e(jour["astreinte_h"])}</span>')
nb = len(events)
label = f'{nb} intervention' + ('s' if nb != 1 else '')

View File

@ -130,9 +130,9 @@
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é, fin arrondie au quart d'heure entamé">retour ≥ {{ j.retour }}</span>{% endif %}
{% 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 ≥ {{ j.retour }}</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="Décompte par quarts d'heure de l'horloge entamés (:00, :15, :30, :45), chacun compté 15 min entières — compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_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 %}
</div>
<div class="as-body">
<div class="as-grid" id="as-events-{{ j.date }}">

View File

@ -47,10 +47,11 @@
<div class="as-decompte">
<div class="as-decompte-title">Astreinte — compteur séparé, hors heures de semaine</div>
<div class="as-decompte-help">
Décompte par <b>quarts d'heure de l'horloge</b> (:00, :15, :30, :45) : tout quart
<b>entamé</b> compte 15 min entières, et un quart partagé par deux
interventions n'est compté qu'<b>une seule fois</b>. Ex. 22:39 → 23:49 =
1h10 réelles mais 6 quarts touchés (22:30 → 00:00) = 1h30.
Décompte par <b>durée réelle arrondie au quart d'heure supérieur</b> : 2, 5 ou
12 min réelles → <b>15 min</b> ; 22 ou 28 → <b>30 min</b> — même si l'arrondi ne
tombe pas juste sur un quart d'heure de l'horloge (:00, :15, :30, :45). Les
minutes réelles communes à deux interventions ne comptent qu'<b>une seule
fois</b>. Ex. 22:39 → 23:49 = 1h10 réelles → arrondies à 1h15.
</div>
{% for j in result.astreinte.jours %}
<div class="as-jour">
@ -64,11 +65,11 @@
<span class="as-ev-h">{{ ev.debut }} → {{ ev.fin }}</span>
{% if ev.desc %}<span class="as-ev-desc">{{ ev.desc }}</span>{% endif %}
{% if ev.tranches %}
<span class="as-ev-q" title="Quarts d'heure de l'horloge (:00, :15, :30, :45) touchés par l'intervention et pas déjà comptés, chacun compté 15 min entières">{{ ev.tranches }} quart{{ 's' if ev.tranches > 1 else '' }} ({{ ev.tranches_debut }} → {{ ev.tranches_fin }}) = {{ ev.min_h }}</span>
<span class="as-ev-q" title="Durée réelle 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 ; les minutes déjà comptées par une intervention précédente ne sont pas recomptées">{{ ev.reel_h }} réelles{% if ev.min > ev.reel_min %} → arrondi {{ ev.min_h }}{% endif %} ({{ ev.arrondi_debut }} → {{ ev.arrondi_fin }})</span>
{% for d in ev.details %}<span class="as-ev-cat">{{ d.nom }} ×{{ d.facteur_txt }}</span>{% if not loop.last %} + {% endif %}{% endfor %}
<span class="as-ev-pond">pondéré → {{ ev.pondere_h }}</span>
{% else %}
<span class="as-ev-q" title="Tous les quarts touchés sont déjà comptés dans une intervention précédente (même quart d'heure)">0 quart — déjà compté</span>
<span class="as-ev-q" title="Toutes les minutes réelles de cette intervention sont déjà comptées dans une intervention précédente (chevauchement)">0 min — déjà compté</span>
{% endif %}
</div>
{% endfor %}

View File

@ -54,11 +54,12 @@
<span class="hist-label">Ratios des heures d'astreinte</span>
<p class="settings-p">
Pondération des heures d'astreinte selon le type de jour et la fenêtre horaire :
le décompte se fait par quarts d'heure de l'horloge (:00, :15, :30, :45) — tout
quart entamé compte 15 min entières (ex. 22:39 → 23:49 touche les quarts 22:30 à
23:45, soit 6 quarts = 1h30) — puis chaque quart est multiplié par le ratio de la
première règle qui le couvre. Un quart hors règle compte ×1 (« Autre ») ; la
première règle qui couvre un quart gagne, place les fenêtres spécifiques avant
le décompte se fait par durée réelle arrondie au quart d'heure supérieur (2, 5
ou 12 min réelles → 15 min ; 22 ou 28 → 30 min — ex. 22:39 → 23:49 = 1h10
réelles → 1h15), même si l'arrondi ne tombe pas juste sur un quart d'heure de
l'horloge — puis chaque minute comptée est multipliée par le ratio de la
première règle qui la couvre. Une minute hors règle compte ×1 (« Autre ») ; la
première règle qui couvre une minute gagne, place les fenêtres spécifiques avant
les générales. Samedi, dimanche et jours fériés comptent de 0h00 à 0h00. Règles
globales à tous les comptes.
</p>

View File

@ -1,4 +1,4 @@
"""Astreinte : événements, décompte par tranches de 15 min, repos de 11h et retour décalé."""
"""Astreinte : événements, durée réelle arrondie au quart d'heure supérieur, repos de 11h et retour décalé."""
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,
@ -117,7 +117,7 @@ def test_retour_fin_du_soir_sur_le_jour_d_astreinte():
def test_retour_fin_hors_quart_arrondie_au_quart_entame():
"""Event 21:00→23:37 : le quart d'heure entamé est travaillé, fin comptée
"""Event 21:00→23:37 : durée réelle 157 min arrondie à 165, fin comptée
23:45 → repos terminé 10:45 le lendemain."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "21:00", "fin": "23:37"}])}
@ -125,7 +125,8 @@ def test_retour_fin_hors_quart_arrondie_au_quart_entame():
def test_retour_fin_nocturne_hors_quart_arrondie():
"""Event 23:00→01:07 (à cheval sur minuit) : fin comptée 01:15 → retour 12:15."""
"""Event 23:00→01:07 (à cheval sur minuit) : durée 127 min arrondie à 135,
fin comptée 01:15 → retour 12:15."""
p_by_date = {"2026-07-14": _pointage("2026-07-14",
astreinte_events=[{"debut": "23:00", "fin": "01:07"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60 + 15
@ -169,25 +170,36 @@ def test_retour_fin_tot_ne_contraint_rien():
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
# ── Décompte astreinte : tranches de 15 min entamées, par règle ───────────────
# ── Décompte astreinte : durée réelle arrondie au quart supérieur, par règle ───
def test_decompte_tranche_entamee_comptee_entiere():
"""22:07→22:20 touche les tranches alignées 22:00 et 22:15 : comptées entières."""
def test_decompte_duree_reelle_arrondie_au_quart_superieur():
"""22:07→22:20 = 13 min réelles → arrondies à 15 min (et non aux tranches
d'horloge 22:00 et 22:15 qui auraient donné 30)."""
d = decompte_astreinte("2026-07-13", [{"debut": "22:07", "fin": "22:20"}])
assert d["tranches"] == 2 # tranches 22:00 + 22:15 entamées
assert d["total_min"] == 2 * PAS_ASTREINTE_MIN # 30
assert d["total_h"] == "0h30"
assert d["tranches"] == 1 # 15 min = 1 quart d'heure
assert d["total_min"] == PAS_ASTREINTE_MIN
assert d["total_h"] == "0h15"
assert d["categories"][0]["nom"] == "Nuit" # lundi 22:xx → Nuit ×1,5
assert d["total_pondere_min"] == 45 # 30 × 1,5
assert d["total_pondere_min"] == 23 # 15 × 1,5
def test_decompte_arrondi_examples_minutes_reelles():
"""2, 5 ou 12 minutes réelles → 15 min ; 22 ou 28 → 30 min — même si
l'arrondi ne tombe pas juste sur un quart d'heure de l'horloge."""
for deb, fin, attendu in [("22:00", "22:02", 15), ("22:00", "22:05", 15),
("22:00", "22:12", 15), ("22:00", "22:22", 30),
("22:00", "22:28", 30), ("22:39", "23:49", 75)]:
d = decompte_astreinte("2026-07-13", [{"debut": deb, "fin": fin}])
assert d["total_min"] == attendu, f"{deb} → {fin}"
def test_decompte_arrondi_des_bornes():
"""09:50→10:05 touche les tranches 09:45 et 10:00 : 2 tranches en Journée ×1."""
"""09:50→10:05 = 15 min réelles → 15 min, en Journée ×1."""
d = decompte_astreinte("2026-07-13", [{"debut": "09:50", "fin": "10:05"}])
assert d["tranches"] == 2 # tranches 09:45 + 10:00
assert d["total_min"] == 30
assert d["tranches"] == 1
assert d["total_min"] == 15
assert d["categories"][0]["nom"] == "Journée" # lundi en journée ×1
assert d["total_pondere_min"] == 30
assert d["total_pondere_min"] == 15
def test_decompte_evenement_a_cheval_sur_minuit():
@ -218,36 +230,33 @@ def test_decompte_vendredi_vers_samedi_reste_en_nuit():
def test_decompte_chevauchement_dedouble():
"""10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent
qu'une fois, attribuées au premier événement ; les lignes somment au total."""
"""10:00→10:20 et 10:10→10:30 se recouvrent : les minutes réelles communes
ne comptent qu'une fois (attribuées au premier événement) ; chaque
intervention est arrondie sur ses seules minutes non déjà comptées."""
d = decompte_astreinte("2026-07-13", [
{"debut": "10:00", "fin": "10:20"},
{"debut": "10:10", "fin": "10:30"},
])
assert len(d["events"]) == 2
assert d["events"][0]["tranches"] == 2 # tranches 10:00 + 10:15
assert d["events"][0]["min"] == 30
assert d["events"][1]["tranches"] == 0 # les deux tranches déjà comptées
assert d["events"][1]["min"] == 0
assert d["tranches"] == 2 # union dédoublonnée
assert d["total_min"] == 30
assert d["events"][0]["min"] == 30 # 20 min réelles → arrondies à 30
assert d["events"][1]["min"] == 15 # 10 min nouvelles → arrondies à 15
assert d["total_min"] == 45
assert sum(e["min"] for e in d["events"]) == d["total_min"]
assert d["categories"][0]["nom"] == "Journée"
def test_decompte_quart_partage_entre_interventions():
"""Deux interventions qui se suivent et finissent/débutent dans le même quart :
le quart n'est compté qu'une fois (attribué à la première), les lignes
d'événements somment au total du jour."""
def test_decompte_minutes_partagees_entre_interventions():
"""Deux interventions qui se chevauchent : les minutes réelles communes ne
sont comptées qu'une fois (attribuées à la première) ; chaque ligne est
arrondie au quart supérieur et les événements somment au total du jour."""
d = decompte_astreinte("2026-07-13", [
{"debut": "22:39", "fin": "23:05"},
{"debut": "23:02", "fin": "23:49"},
])
assert d["events"][0]["tranches"] == 3 # 22:30 + 22:45 + 23:00
assert d["events"][1]["tranches"] == 3 # 23:15 + 23:30 + 23:45 (23:00 déjà compté)
assert sum(e["tranches"] for e in d["events"]) == d["tranches"] == 6
assert d["total_min"] == 90
assert d["total_pondere_min"] == 135 # 6 × 15 × 1,5 (Nuit)
assert d["events"][0]["min"] == 30 # 26 min réelles → 30
assert d["events"][1]["min"] == 45 # 44 min nouvelles → 45 (3 min communes)
assert d["total_min"] == 75
assert d["total_pondere_min"] == 113 # 75 × 1,5 (Nuit)
def test_decompte_event_incomplet_ignore():