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:
28
README.md
28
README.md
@ -205,11 +205,11 @@ pointer si besoin — les exports `.ods` suivent.
|
|||||||
|
|
||||||
L'app en déduit l'heure de retour au bureau : **fin du dernier événement + 11h de
|
L'app en déduit l'heure de retour au bureau : **fin du dernier événement + 11h de
|
||||||
repos consécutif** (exigées par période de 24h), affichée sous la liste (« retour
|
repos consécutif** (exigées par période de 24h), affichée sous la liste (« retour
|
||||||
≥ 13:00 »). Tout quart d'heure entamé est travaillé : la fin est arrondie au quart
|
≥ 13:00 »). Comme pour le décompte, la durée réelle de l'intervention est arrondie
|
||||||
d'heure supérieur (fin 23:37 → comptée 23:45) ; les interventions hors fenêtre
|
au quart d'heure supérieur (début 21:00, fin 23:37 → durée comptée 2h45) ; les
|
||||||
18:00→18:00 ne sont pas de l'astreinte. Fin 23:30 → retour 10:30 le lendemain ;
|
interventions hors fenêtre 18:00→18:00 ne sont pas de l'astreinte. Fin 23:30 →
|
||||||
fin 02:00 → retour 13:00 le jour même. Un repos qui se termine avant l'ouverture
|
retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le jour même. Un repos qui se
|
||||||
ne contraint rien.
|
termine avant l'ouverture ne contraint rien.
|
||||||
|
|
||||||
À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le
|
À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le
|
||||||
décompte de la semaine :
|
décompte de la semaine :
|
||||||
@ -222,12 +222,14 @@ décompte de la semaine :
|
|||||||
- une demi-journée entièrement couverte par le repos est considérée comme remplie.
|
- une demi-journée entièrement couverte par le repos est considérée comme remplie.
|
||||||
|
|
||||||
Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles
|
Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles
|
||||||
sont décomptées à part, par quarts d'heure de l'horloge (:00, :15, :30, :45) —
|
sont décomptées à part — la **durée réelle** de chaque intervention est **arrondie
|
||||||
toute tranche entamée compte en totalité, et un quart partagé par deux
|
au quart d'heure supérieur** (2, 5 ou 12 min réelles → 15 min ; 22 ou 28 → 30 min),
|
||||||
interventions qui se suivent ou se chevauchent n'est compté **qu'une fois**
|
même si l'arrondi ne tombe pas juste sur un quart d'heure de l'horloge (:00, :15,
|
||||||
(attribué à la première, les lignes d'événements somment au total du jour) — dans
|
:30, :45), et les minutes réelles communes à deux interventions qui se suivent ou
|
||||||
des compteurs pondérés selon le jour et l'heure. Sous le bandeau de la semaine
|
se chevauchent ne sont comptées **qu'une fois** (attribuées à la première, les
|
||||||
s'affiche le détail par événement, les totaux par compteur et le total pondéré.
|
lignes d'événements somment au total du jour) — dans des compteurs pondérés selon
|
||||||
|
le jour et l'heure. Sous le bandeau de la semaine s'affiche le détail par
|
||||||
|
événement, les totaux par compteur et le total pondéré.
|
||||||
|
|
||||||
Par défaut : **Nuit** (20:00→08:00, tous types de jours) ×1,5 ; **Journée** (semaine
|
Par défaut : **Nuit** (20:00→08:00, tous types de jours) ×1,5 ; **Journée** (semaine
|
||||||
Lun-Ven, 08:00→20:00) ×1 ; **Samedi** (08:00→20:00) ×1,5 ; **Dimanche / férié**
|
Lun-Ven, 08:00→20:00) ×1 ; **Samedi** (08:00→20:00) ×1,5 ; **Dimanche / férié**
|
||||||
@ -236,11 +238,11 @@ Pentecôte) sont calculés automatiquement, et s'ajoutent les jours marqués **F
|
|||||||
|
|
||||||
Ces règles sont **éditables à la main** sur la page **Réglages** (section « Ratios
|
Ces règles sont **éditables à la main** sur la page **Réglages** (section « Ratios
|
||||||
des heures d'astreinte ») : nom, types de jours, fenêtre horaire et ratio de chaque
|
des heures d'astreinte ») : nom, types de jours, fenêtre horaire et ratio de chaque
|
||||||
règle, ajout et suppression de lignes. La première règle qui couvre un quart
|
règle, ajout et suppression de lignes. La première règle qui couvre une minute
|
||||||
gagne — place les fenêtres spécifiques avant les générales. Elles restent aussi
|
gagne — place les fenêtres spécifiques avant les générales. Elles restent aussi
|
||||||
ajustables dans `data/config.json`, clé `astreinte_facteurs` : chaque règle porte
|
ajustables dans `data/config.json`, clé `astreinte_facteurs` : chaque règle porte
|
||||||
un `nom`, les `jours` concernés (`semaine`, `sam`, `dim`, `ferie`), un `debut`,
|
un `nom`, les `jours` concernés (`semaine`, `sam`, `dim`, `ferie`), un `debut`,
|
||||||
une `fin` et un `facteur`. Une tranche qui ne correspond à aucune règle est rangée
|
une `fin` et un `facteur`. Une minute qui ne correspond à aucune règle est rangée
|
||||||
dans « Autre » avec un facteur ×1.
|
dans « Autre » avec un facteur ×1.
|
||||||
|
|
||||||
Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) :
|
Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) :
|
||||||
|
|||||||
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
|
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
|
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
|
# 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 = [
|
DEFAULT_FACTEURS_ASTREINTE = [
|
||||||
{"nom": "Nuit", "jours": ["semaine", "sam", "dim", "ferie"], "debut": "20:00", "fin": "08:00", "facteur": 1.5},
|
{"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},
|
{"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,
|
def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None,
|
||||||
fenetre: tuple[int, int] | None = None,
|
fenetre: tuple[int, int] | None = None,
|
||||||
feries_declares: list[str] | set[str] | None = None) -> dict:
|
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 :
|
"""Décompte des heures réalisées en astreinte pour un jour : la durée réelle
|
||||||
toute tranche entamée est comptée en totalité. Chaque tranche est attribuée à la
|
de chaque intervention est arrondie au quart d'heure supérieur (2, 5 ou
|
||||||
première règle (type de jour × fenêtre horaire) qui la couvre, puis pondérée par
|
12 min réelles → 15 min ; 22 ou 28 → 30 min), même si l'arrondi ne tombe
|
||||||
son facteur. Les événements vivent sur la timeline réelle : fin < début =
|
pas juste sur un quart d'heure de l'horloge (:00, :15, :30, :45). Chaque
|
||||||
intervention à cheval sur minuit, dont la partie après minuit compte sur le jour
|
minute comptée est attribuée à la première règle (type de jour × fenêtre
|
||||||
suivant. Les tranches couvertes par plusieurs événements ne sont comptées
|
horaire) qui la couvre, puis pondérée par son facteur. Les événements vivent
|
||||||
qu'une fois, attribuées au premier événement qui les entame (ordre
|
sur la timeline réelle : fin < début = intervention à cheval sur minuit,
|
||||||
chronologique) : les compteurs des événements somment au total du jour.
|
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)
|
`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.
|
é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
|
`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 ».
|
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)
|
regles = _regles_astreinte(facteurs)
|
||||||
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
|
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
|
# Fériés légaux (calculés) + fériés marqués à la main : les deux qualifient
|
||||||
# quarts pour les règles de type « ferie ».
|
# les minutes pour les règles de type « ferie ».
|
||||||
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
|
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
|
||||||
for s in (feries_declares or []):
|
for s in (feries_declares or []):
|
||||||
try:
|
try:
|
||||||
@ -212,17 +215,16 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
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)
|
cat_j = _categorie_jour(_EPOCH_DATE + timedelta(days=qm // 1440), feries)
|
||||||
minute = qm % 1440
|
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)
|
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 ;
|
vus: set[int] = set()
|
||||||
# la première affectation gagne si des événements se chevauchent.
|
|
||||||
quart_regles: dict[int, dict | None] = {}
|
|
||||||
events_out = []
|
events_out = []
|
||||||
# Événements triés par début : un quart entamé par plusieurs interventions qui
|
# Événements triés par début : une minute réelle couverte par plusieurs
|
||||||
# se suivent ou se chevauchent n'est compté qu'une fois, attribué à la première.
|
# interventions qui se suivent ou se chevauchent n'est comptée qu'une fois,
|
||||||
|
# attribuée à la première.
|
||||||
parses = []
|
parses = []
|
||||||
for e in events:
|
for e in events:
|
||||||
deb, fin = e.get("debut"), e.get("fin")
|
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
|
deb_min, duree = debut_c, fin_c - debut_c
|
||||||
parses.append((e, deb, fin, deb_min, duree))
|
parses.append((e, deb, fin, deb_min, duree))
|
||||||
parses.sort(key=lambda t: (t[3], t[3] + t[4]))
|
parses.sort(key=lambda t: (t[3], t[3] + t[4]))
|
||||||
vus: set[int] = set()
|
|
||||||
for e, deb, fin, deb_min, duree in parses:
|
for e, deb, fin, deb_min, duree in parses:
|
||||||
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
|
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
|
||||||
quarts = [qm for qm in range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
|
nouvelles = [qm for qm in range(a0, a0 + duree) if qm not in vus]
|
||||||
a0 + duree, PAS_ASTREINTE_MIN)
|
vus.update(range(a0, a0 + duree))
|
||||||
if qm not in vus]
|
ev_reel = len(nouvelles)
|
||||||
vus.update(quarts)
|
# Durée réelle arrondie au quart d'heure supérieur : 2, 5 ou 12 min
|
||||||
for qm in quarts:
|
# réelles → 15 ; 22 ou 28 → 30 — même hors des quarts d'horloge.
|
||||||
quart_regles.setdefault(qm, _regle_du_quart(qm))
|
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] = {}
|
ev_cats: dict[str, dict] = {}
|
||||||
for qm in quarts:
|
for qm in range(arrondi0, arrondi0 + ev_min):
|
||||||
regle = quart_regles.get(qm)
|
regle = _regle_de_la_minute(qm)
|
||||||
nom = regle["nom"] if regle else "Autre"
|
nom = regle["nom"] if regle else "Autre"
|
||||||
facteur = regle["facteur"] if regle else 1.0
|
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 = ev_cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "min": 0})
|
||||||
c["tranches"] += 1
|
c["min"] += 1
|
||||||
ev_min = len(quarts) * PAS_ASTREINTE_MIN
|
|
||||||
ev_details = [
|
ev_details = [
|
||||||
{**c, "min": c["tranches"] * PAS_ASTREINTE_MIN,
|
{**c, "min_h": minutes_to_hhmm(c["min"]),
|
||||||
"min_h": minutes_to_hhmm(c["tranches"] * PAS_ASTREINTE_MIN),
|
"pondere_min": int(c["min"] * c["facteur"] + 0.5)}
|
||||||
"pondere_min": int(c["tranches"] * PAS_ASTREINTE_MIN * c["facteur"] + 0.5)}
|
|
||||||
for c in ev_cats.values()
|
for c in ev_cats.values()
|
||||||
]
|
]
|
||||||
ev_pondere = sum(d["pondere_min"] for d in ev_details)
|
ev_pondere = sum(d["pondere_min"] for d in ev_details)
|
||||||
events_out.append({
|
events_out.append({
|
||||||
"debut": deb, "fin": fin, "desc": e.get("desc") or "",
|
"debut": deb, "fin": fin, "desc": e.get("desc") or "",
|
||||||
"tranches_debut": minutes_to_time(quarts[0] % 1440) if quarts else "",
|
"reel_min": ev_reel, "reel_h": minutes_to_hhmm(ev_reel),
|
||||||
"tranches_fin": minutes_to_time((quarts[-1] + PAS_ASTREINTE_MIN) % 1440) if quarts else "",
|
"arrondi_debut": minutes_to_time(arrondi0 % 1440) if ev_min else "",
|
||||||
"tranches": len(quarts), "min": ev_min, "min_h": minutes_to_hhmm(ev_min),
|
"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),
|
"pondere_min": ev_pondere, "pondere_h": minutes_to_hhmm(ev_pondere),
|
||||||
"details": ev_details,
|
"details": ev_details,
|
||||||
})
|
})
|
||||||
|
|
||||||
cats: dict[str, dict] = {}
|
cats: dict[str, dict] = {}
|
||||||
for qm, regle in quart_regles.items():
|
for ev in events_out:
|
||||||
nom = regle["nom"] if regle else "Autre"
|
for d in ev["details"]:
|
||||||
facteur = regle["facteur"] if regle else 1.0
|
c = cats.setdefault(d["nom"], {"nom": d["nom"], "facteur": d["facteur"], "facteur_txt": d["facteur_txt"], "min": 0})
|
||||||
c = cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "tranches": 0})
|
c["min"] += d["min"]
|
||||||
c["tranches"] += 1
|
|
||||||
categories = []
|
categories = []
|
||||||
for nom, c in cats.items():
|
for nom, c in cats.items():
|
||||||
cmin = c["tranches"] * PAS_ASTREINTE_MIN
|
categories.append({**c, "min_h": minutes_to_hhmm(c["min"]),
|
||||||
categories.append({**c, "min": cmin, "min_h": minutes_to_hhmm(cmin),
|
"pondere_min": int(c["min"] * c["facteur"] + 0.5),
|
||||||
"pondere_min": int(cmin * c["facteur"] + 0.5),
|
"pondere_h": minutes_to_hhmm(int(c["min"] * c["facteur"] + 0.5))})
|
||||||
"pondere_h": minutes_to_hhmm(int(cmin * c["facteur"] + 0.5))})
|
|
||||||
ordre = [r["nom"] for r in regles] + ["Autre"]
|
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))
|
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)
|
total_pondere = sum(c["pondere_min"] for c in categories)
|
||||||
return {
|
return {
|
||||||
"tranches": len(quart_regles),
|
"tranches": total_min // PAS_ASTREINTE_MIN,
|
||||||
"total_min": total_min,
|
"total_min": total_min,
|
||||||
"total_h": minutes_to_hhmm(total_min),
|
"total_h": minutes_to_hhmm(total_min),
|
||||||
"total_pondere_min": total_pondere,
|
"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
|
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) :
|
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é
|
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
|
n'est pas de l'astreinte et ne déclenche pas de repos. Comme pour le décompte, la durée
|
||||||
entamé est travaillé : la fin est arrondie au quart d'heure supérieur avant
|
réelle de l'intervention est arrondie au quart d'heure supérieur : la fin
|
||||||
d'ajouter le repos. Le retour s'applique au jour où se termine le repos :
|
comptée est le début + la durée arrondie, avant d'ajouter le repos. Le retour
|
||||||
fin 23:30 → retour 10:30 le lendemain ; fin 23:37 (comptée 23:45) → retour
|
s'applique au jour où se termine le repos : fin 23:30 → retour 10:30 le
|
||||||
10:45 ; fin 02:00 → retour 13:00 le jour même.
|
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
|
Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour
|
||||||
au-delà de l'ouverture."""
|
au-delà de l'ouverture."""
|
||||||
astreintes = set(astreintes or [])
|
astreintes = set(astreintes or [])
|
||||||
@ -327,17 +329,19 @@ dernière activité d'astreinte.
|
|||||||
if deb and fin:
|
if deb and fin:
|
||||||
yield hhmm_to_minutes(deb), hhmm_to_minutes(fin)
|
yield hhmm_to_minutes(deb), hhmm_to_minutes(fin)
|
||||||
|
|
||||||
def _fin_comptee(fin: int) -> int:
|
def _fin_comptee(deb: int, fin: int) -> int:
|
||||||
"""Fin d'activité effectivement travaillée : toute tranche de 15 min
|
"""Fin d'activité effectivement comptée (sur la timeline du jour du
|
||||||
entamée compte, la fin est arrondie au quart d'heure supérieur."""
|
début) : durée réelle arrondie au quart d'heure supérieur (2, 5 ou
|
||||||
return -(-fin // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
|
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
|
# É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
|
# 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.
|
# y termine une intervention nocturne et le repos s'achève aujourd'hui.
|
||||||
if veille in astreintes:
|
if veille in astreintes:
|
||||||
for deb, fin in _fins(date_str):
|
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:
|
if fin >= deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
||||||
cands.append(fin_c + REPOS_QUOTIDIEN_MIN)
|
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
|
# É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.
|
# aujourd'hui.
|
||||||
if veille in astreintes:
|
if veille in astreintes:
|
||||||
for deb, fin in _fins(veille):
|
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:
|
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)
|
cands.append(fin_c + REPOS_QUOTIDIEN_MIN - 24 * 60)
|
||||||
if fin < deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
if fin < deb and fin_c - 24 * 60 + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
||||||
cands.append(fin_c + REPOS_QUOTIDIEN_MIN)
|
cands.append(fin_c - 24 * 60 + REPOS_QUOTIDIEN_MIN)
|
||||||
if not cands:
|
if not cands:
|
||||||
return None
|
return None
|
||||||
best = max(cands)
|
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
|
# 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)
|
# dernière activité d'astreinte (saisie sur le jour ou la veille qualifiés)
|
||||||
retour_min = retour_apres_repos(p["date"], p_by_date, plages, astreintes)
|
retour_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 ;
|
# Décompte astreinte du jour : événements multiples, durée réelle arrondie
|
||||||
# un férié marqué à la main qualifie les quarts pour les règles « ferie »
|
# 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 []
|
events = p.get("astreinte_events") or []
|
||||||
dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes, feries), feries)
|
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)
|
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_min += dec["total_min"]
|
||||||
as_total_pondere_min += dec["total_pondere_min"]
|
as_total_pondere_min += dec["total_pondere_min"]
|
||||||
for c in dec["categories"]:
|
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 = as_cats.setdefault(c["nom"], {"nom": c["nom"], "facteur": c["facteur"], "min": 0, "pondere_min": 0})
|
||||||
fc["tranches"] += c["tranches"]
|
|
||||||
fc["min"] += c["min"]
|
fc["min"] += c["min"]
|
||||||
fc["pondere_min"] += c["pondere_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,
|
"nom": nom,
|
||||||
"facteur": c["facteur"],
|
"facteur": c["facteur"],
|
||||||
"facteur_txt": _facteur_txt(c["facteur"]),
|
"facteur_txt": _facteur_txt(c["facteur"]),
|
||||||
"tranches": c["tranches"],
|
|
||||||
"min": c["min"],
|
"min": c["min"],
|
||||||
"min_h": minutes_to_hhmm(c["min"]),
|
"min_h": minutes_to_hhmm(c["min"]),
|
||||||
"pondere_min": c["pondere_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, {})
|
rows = "".join(_as_event_html(date, ev) for ev in events) or _as_event_html(date, {})
|
||||||
hint = ""
|
hint = ""
|
||||||
if jour.get("retour_min") is not None:
|
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>')
|
f'retour ≥ {_e(jour["retour"])}</span>')
|
||||||
if jour.get("forfait_min"):
|
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">'
|
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'forfait {_e(jour["forfait_h"])}</span>')
|
||||||
total = ""
|
total = ""
|
||||||
if jour.get("astreinte_min"):
|
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>')
|
f'astreinte : {_e(jour["astreinte_h"])}</span>')
|
||||||
nb = len(events)
|
nb = len(events)
|
||||||
label = f'{nb} intervention' + ('s' if nb != 1 else '')
|
label = f'{nb} intervention' + ('s' if nb != 1 else '')
|
||||||
|
|||||||
@ -130,9 +130,9 @@
|
|||||||
onclick="asToggle(this)">
|
onclick="asToggle(this)">
|
||||||
<span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
|
<span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
|
||||||
</button>
|
</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.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>
|
||||||
<div class="as-body">
|
<div class="as-body">
|
||||||
<div class="as-grid" id="as-events-{{ j.date }}">
|
<div class="as-grid" id="as-events-{{ j.date }}">
|
||||||
|
|||||||
@ -47,10 +47,11 @@
|
|||||||
<div class="as-decompte">
|
<div class="as-decompte">
|
||||||
<div class="as-decompte-title">Astreinte — compteur séparé, hors heures de semaine</div>
|
<div class="as-decompte-title">Astreinte — compteur séparé, hors heures de semaine</div>
|
||||||
<div class="as-decompte-help">
|
<div class="as-decompte-help">
|
||||||
Décompte par <b>quarts d'heure de l'horloge</b> (:00, :15, :30, :45) : tout quart
|
Décompte par <b>durée réelle arrondie au quart d'heure supérieur</b> : 2, 5 ou
|
||||||
<b>entamé</b> compte 15 min entières, et un quart partagé par deux
|
12 min réelles → <b>15 min</b> ; 22 ou 28 → <b>30 min</b> — même si l'arrondi ne
|
||||||
interventions n'est compté qu'<b>une seule fois</b>. Ex. 22:39 → 23:49 =
|
tombe pas juste sur un quart d'heure de l'horloge (:00, :15, :30, :45). Les
|
||||||
1h10 réelles mais 6 quarts touchés (22:30 → 00:00) = 1h30.
|
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>
|
</div>
|
||||||
{% for j in result.astreinte.jours %}
|
{% for j in result.astreinte.jours %}
|
||||||
<div class="as-jour">
|
<div class="as-jour">
|
||||||
@ -64,11 +65,11 @@
|
|||||||
<span class="as-ev-h">{{ ev.debut }} → {{ ev.fin }}</span>
|
<span class="as-ev-h">{{ ev.debut }} → {{ ev.fin }}</span>
|
||||||
{% if ev.desc %}<span class="as-ev-desc">{{ ev.desc }}</span>{% endif %}
|
{% if ev.desc %}<span class="as-ev-desc">{{ ev.desc }}</span>{% endif %}
|
||||||
{% if ev.tranches %}
|
{% 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 %}
|
{% 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>
|
<span class="as-ev-pond">pondéré → {{ ev.pondere_h }}</span>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -54,11 +54,12 @@
|
|||||||
<span class="hist-label">Ratios des heures d'astreinte</span>
|
<span class="hist-label">Ratios des heures d'astreinte</span>
|
||||||
<p class="settings-p">
|
<p class="settings-p">
|
||||||
Pondération des heures d'astreinte selon le type de jour et la fenêtre horaire :
|
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
|
le décompte se fait par durée réelle arrondie au quart d'heure supérieur (2, 5
|
||||||
quart entamé compte 15 min entières (ex. 22:39 → 23:49 touche les quarts 22:30 à
|
ou 12 min réelles → 15 min ; 22 ou 28 → 30 min — ex. 22:39 → 23:49 = 1h10
|
||||||
23:45, soit 6 quarts = 1h30) — puis chaque quart est multiplié par le ratio de la
|
réelles → 1h15), même si l'arrondi ne tombe pas juste sur un quart d'heure de
|
||||||
première règle qui le couvre. Un quart hors règle compte ×1 (« Autre ») ; la
|
l'horloge — puis chaque minute comptée est multipliée par le ratio de la
|
||||||
première règle qui couvre un quart gagne, place les fenêtres spécifiques avant
|
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
|
les générales. Samedi, dimanche et jours fériés comptent de 0h00 à 0h00. Règles
|
||||||
globales à tous les comptes.
|
globales à tous les comptes.
|
||||||
</p>
|
</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 (
|
from calcul import (
|
||||||
compute_week, decompte_astreinte, heures_dues, heures_travaillees, hhmm_to_minutes,
|
compute_week, decompte_astreinte, heures_dues, heures_travaillees, hhmm_to_minutes,
|
||||||
jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, fenetre_astreinte_jour,
|
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():
|
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."""
|
23:45 → repos terminé 10:45 le lendemain."""
|
||||||
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||||||
astreinte_events=[{"debut": "21:00", "fin": "23:37"}])}
|
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():
|
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",
|
p_by_date = {"2026-07-14": _pointage("2026-07-14",
|
||||||
astreinte_events=[{"debut": "23:00", "fin": "01:07"}])}
|
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
|
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
|
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():
|
def test_decompte_duree_reelle_arrondie_au_quart_superieur():
|
||||||
"""22:07→22:20 touche les tranches alignées 22:00 et 22:15 : comptées entières."""
|
"""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"}])
|
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["tranches"] == 1 # 15 min = 1 quart d'heure
|
||||||
assert d["total_min"] == 2 * PAS_ASTREINTE_MIN # 30
|
assert d["total_min"] == PAS_ASTREINTE_MIN
|
||||||
assert d["total_h"] == "0h30"
|
assert d["total_h"] == "0h15"
|
||||||
assert d["categories"][0]["nom"] == "Nuit" # lundi 22:xx → Nuit ×1,5
|
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():
|
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"}])
|
d = decompte_astreinte("2026-07-13", [{"debut": "09:50", "fin": "10:05"}])
|
||||||
assert d["tranches"] == 2 # tranches 09:45 + 10:00
|
assert d["tranches"] == 1
|
||||||
assert d["total_min"] == 30
|
assert d["total_min"] == 15
|
||||||
assert d["categories"][0]["nom"] == "Journée" # lundi en journée ×1
|
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():
|
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():
|
def test_decompte_chevauchement_dedouble():
|
||||||
"""10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent
|
"""10:00→10:20 et 10:10→10:30 se recouvrent : les minutes réelles communes
|
||||||
qu'une fois, attribuées au premier événement ; les lignes somment au total."""
|
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", [
|
d = decompte_astreinte("2026-07-13", [
|
||||||
{"debut": "10:00", "fin": "10:20"},
|
{"debut": "10:00", "fin": "10:20"},
|
||||||
{"debut": "10:10", "fin": "10:30"},
|
{"debut": "10:10", "fin": "10:30"},
|
||||||
])
|
])
|
||||||
assert len(d["events"]) == 2
|
assert len(d["events"]) == 2
|
||||||
assert d["events"][0]["tranches"] == 2 # tranches 10:00 + 10:15
|
assert d["events"][0]["min"] == 30 # 20 min réelles → arrondies à 30
|
||||||
assert d["events"][0]["min"] == 30
|
assert d["events"][1]["min"] == 15 # 10 min nouvelles → arrondies à 15
|
||||||
assert d["events"][1]["tranches"] == 0 # les deux tranches déjà comptées
|
assert d["total_min"] == 45
|
||||||
assert d["events"][1]["min"] == 0
|
|
||||||
assert d["tranches"] == 2 # union dédoublonnée
|
|
||||||
assert d["total_min"] == 30
|
|
||||||
assert sum(e["min"] for e in d["events"]) == d["total_min"]
|
assert sum(e["min"] for e in d["events"]) == d["total_min"]
|
||||||
assert d["categories"][0]["nom"] == "Journée"
|
assert d["categories"][0]["nom"] == "Journée"
|
||||||
|
|
||||||
|
|
||||||
def test_decompte_quart_partage_entre_interventions():
|
def test_decompte_minutes_partagees_entre_interventions():
|
||||||
"""Deux interventions qui se suivent et finissent/débutent dans le même quart :
|
"""Deux interventions qui se chevauchent : les minutes réelles communes ne
|
||||||
le quart n'est compté qu'une fois (attribué à la première), les lignes
|
sont comptées qu'une fois (attribuées à la première) ; chaque ligne est
|
||||||
d'événements somment au total du jour."""
|
arrondie au quart supérieur et les événements somment au total du jour."""
|
||||||
d = decompte_astreinte("2026-07-13", [
|
d = decompte_astreinte("2026-07-13", [
|
||||||
{"debut": "22:39", "fin": "23:05"},
|
{"debut": "22:39", "fin": "23:05"},
|
||||||
{"debut": "23:02", "fin": "23:49"},
|
{"debut": "23:02", "fin": "23:49"},
|
||||||
])
|
])
|
||||||
assert d["events"][0]["tranches"] == 3 # 22:30 + 22:45 + 23:00
|
assert d["events"][0]["min"] == 30 # 26 min réelles → 30
|
||||||
assert d["events"][1]["tranches"] == 3 # 23:15 + 23:30 + 23:45 (23:00 déjà compté)
|
assert d["events"][1]["min"] == 45 # 44 min nouvelles → 45 (3 min communes)
|
||||||
assert sum(e["tranches"] for e in d["events"]) == d["tranches"] == 6
|
assert d["total_min"] == 75
|
||||||
assert d["total_min"] == 90
|
assert d["total_pondere_min"] == 113 # 75 × 1,5 (Nuit)
|
||||||
assert d["total_pondere_min"] == 135 # 6 × 15 × 1,5 (Nuit)
|
|
||||||
|
|
||||||
|
|
||||||
def test_decompte_event_incomplet_ignore():
|
def test_decompte_event_incomplet_ignore():
|
||||||
|
|||||||
Reference in New Issue
Block a user