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:
137
app/calcul.py
137
app/calcul.py
@ -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"],
|
||||
|
||||
@ -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'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'heure de l'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 '')
|
||||
|
||||
@ -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 }}">
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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():
|
||||
|
||||
Reference in New Issue
Block a user