diff --git a/README.md b/README.md index 1a19232..96f9df9 100644 --- a/README.md +++ b/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 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 -d'heure supérieur (fin 23:37 → comptée 23:45) ; les interventions hors fenêtre -18:00→18:00 ne sont pas de l'astreinte. Fin 23:30 → retour 10:30 le lendemain ; -fin 02:00 → retour 13:00 le jour même. Un repos qui se termine avant l'ouverture -ne contraint rien. +≥ 13:00 »). Comme pour le décompte, la durée réelle de l'intervention est arrondie +au quart d'heure supérieur (début 21:00, fin 23:37 → durée comptée 2h45) ; les +interventions hors fenêtre 18:00→18:00 ne sont pas de l'astreinte. Fin 23:30 → +retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le jour même. Un repos qui se +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 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. 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) — -toute tranche entamée compte en totalité, et un quart partagé par deux -interventions qui se suivent ou se chevauchent n'est compté **qu'une fois** -(attribué à la première, les 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é. +sont décomptées à part — 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), et les minutes réelles communes à deux interventions qui se suivent ou +se chevauchent ne sont comptées **qu'une fois** (attribuées à la première, les +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 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 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 ajustables dans `data/config.json`, clé `astreinte_facteurs` : chaque règle porte 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. Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) : diff --git a/app/calcul.py b/app/calcul.py index 879b313..ee71c42 100644 --- a/app/calcul.py +++ b/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"], diff --git a/app/main.py b/app/main.py index cfd229f..33ed161 100644 --- a/app/main.py +++ b/app/main.py @@ -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'' + hint = (f'' f'retour ≥ {_e(jour["retour"])}') if jour.get("forfait_min"): hint += (f'' f'forfait {_e(jour["forfait_h"])}') total = "" if jour.get("astreinte_min"): - total = (f'' + total = (f'' f'astreinte : {_e(jour["astreinte_h"])}') nb = len(events) label = f'{nb} intervention' + ('s' if nb != 1 else '') diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index 1a6943f..f6c5fcd 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -130,9 +130,9 @@ onclick="asToggle(this)"> ▸ {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }} - {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} + {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} {% if j.forfait_min %}forfait {{ j.forfait_h }}{% endif %} - {% if j.astreinte_min %}astreinte : {{ j.astreinte_h }}{% endif %} + {% if j.astreinte_min %}astreinte : {{ j.astreinte_h }}{% endif %}
diff --git a/app/templates/_soldes.html b/app/templates/_soldes.html index e088053..2c8eca2 100644 --- a/app/templates/_soldes.html +++ b/app/templates/_soldes.html @@ -47,10 +47,11 @@
Astreinte — compteur séparé, hors heures de semaine
- Décompte par quarts d'heure de l'horloge (:00, :15, :30, :45) : tout quart - entamé compte 15 min entières, et un quart partagé par deux - interventions n'est compté qu'une seule fois. Ex. 22:39 → 23:49 = - 1h10 réelles mais 6 quarts touchés (22:30 → 00:00) = 1h30. + Décompte 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 — 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'une seule + fois. Ex. 22:39 → 23:49 = 1h10 réelles → arrondies à 1h15.
{% for j in result.astreinte.jours %}
@@ -64,11 +65,11 @@ {{ ev.debut }} → {{ ev.fin }} {% if ev.desc %}{{ ev.desc }}{% endif %} {% if ev.tranches %} - {{ ev.tranches }} quart{{ 's' if ev.tranches > 1 else '' }} ({{ ev.tranches_debut }} → {{ ev.tranches_fin }}) = {{ ev.min_h }} + {{ ev.reel_h }} réelles{% if ev.min > ev.reel_min %} → arrondi {{ ev.min_h }}{% endif %} ({{ ev.arrondi_debut }} → {{ ev.arrondi_fin }}) {% for d in ev.details %}{{ d.nom }} ×{{ d.facteur_txt }}{% if not loop.last %} + {% endif %}{% endfor %} pondéré → {{ ev.pondere_h }} {% else %} - 0 quart — déjà compté + 0 min — déjà compté {% endif %}
{% endfor %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 5300360..c840c77 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -54,11 +54,12 @@ Ratios des heures d'astreinte

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.

diff --git a/app/tests/test_astreinte.py b/app/tests/test_astreinte.py index 10aaa3a..447de62 100644 --- a/app/tests/test_astreinte.py +++ b/app/tests/test_astreinte.py @@ -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():