diff --git a/README.md b/README.md
index b4a5e4d..c8fb04c 100644
--- a/README.md
+++ b/README.md
@@ -182,17 +182,15 @@ demi-journées restantes.
## Astreinte & repos de 11h
Le bouton **AS** marque un jour d'astreinte. Ce jour et son lendemain (la nuit qui
-suit), une ligne de saisie supplémentaire apparaît sous l'après-midi :
+suit), une liste d'événements apparaît sous l'après-midi : autant de lignes que
+nécessaire (**+ intervention**), chacune avec un **début**, une **fin** et une
+description libre. Une fin antérieure au début (fin < début) désigne une
+intervention à cheval sur minuit.
-- **Fin activité** : l'heure de fin de la **dernière activité** hors horaires de
- travail. Elle peut tomber le matin (intervention ayant dépassé minuit) : saisie
- sur le jour d'astreinte, une fin matinale (ex: 06:00) désigne la fin de sa nuit ;
- saisie sur le lendemain, une fin matinale désigne la nuit de la veille.
-
-L'app en déduit l'heure de retour au bureau : **fin + 11h de repos consécutif**
-(exigées par période de 24h), affichée sous le champ (« retour ≥ 13:00 »). Fin
-23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 ; fin 06:00 → retour
-17:00. Un repos qui se termine avant l'ouverture ne contraint rien.
+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 »). 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 :
@@ -204,9 +202,25 @@ décompte de la semaine :
décalée d'autant, sans avertissement de retard) ;
- une demi-journée entièrement couverte par le repos est considérée comme remplie.
-La fin d'activité ne s'applique que les jours qualifiés (astreinte ou lendemain) :
-partout ailleurs elle est ignorée. Les colonnes **Astreinte** (fin saisie) et
-**Retour** (calculé) apparaissent dans les exports `.ods`.
+Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles
+sont décomptées à part, par pas de 15 minutes (toute tranche entamée compte en
+totalité), 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é**
+(08:00→20:00) ×2. Les jours fériés français (fixes + Pâques, Ascension, Pentecôte)
+sont calculés automatiquement.
+
+Ces règles sont 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 dans « Autre » avec un facteur ×1.
+
+Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) :
+partout ailleurs ils sont ignorés. Les colonnes **Astreinte** (brut), **Astreinte
+pondérée** et **Retour** (calculé) apparaissent dans les exports `.ods`.
---
diff --git a/app/calcul.py b/app/calcul.py
index 7ffd541..48d1815 100644
--- a/app/calcul.py
+++ b/app/calcul.py
@@ -1,5 +1,5 @@
"""Business logic: compute worked hours and expected hours per day/week."""
-from datetime import datetime, timedelta
+from datetime import date, datetime, timedelta
DEFAULT_PLAGES = {
"matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin
@@ -12,6 +12,19 @@ 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é
+
+# Règles de comptage des heures d'astreinte : chaque tranche de 15 min 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.
+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},
+ {"nom": "Samedi", "jours": ["sam"], "debut": "08:00", "fin": "20:00", "facteur": 1.5},
+ {"nom": "Dimanche / férié", "jours": ["dim", "ferie"], "debut": "08:00", "fin": "20:00", "facteur": 2.0},
+]
+
+_EPOCH_DATE = date(2000, 1, 1)
def hhmm_to_minutes(val: str | None) -> int:
@@ -73,48 +86,192 @@ def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, ap
return max(0, m - matin_excl_min), max(0, a - aprem_excl_min)
+# ── Jours fériés français (fixes + mobiles) ───────────────────────────────────
+
+def _paques(annee: int) -> date:
+ """Dimanche de Pâques (algorithme de Meeus/Jones/Butcher)."""
+ a = annee % 19
+ b, c = divmod(annee, 100)
+ d, e = divmod(b, 4)
+ f = (b + 8) // 25
+ g = (b - f + 1) // 3
+ h = (19 * a + b - d - g + 15) % 30
+ i, k = divmod(c, 4)
+ l = (32 + 2 * e + 2 * i - h - k) % 7
+ m = (a + 11 * h + 22 * l) // 451
+ mois, jour = divmod(h + l - 7 * m + 114, 31)
+ return date(annee, mois, jour + 1)
+
+
+def _jours_feries(annee: int) -> set[date]:
+ p = _paques(annee)
+ return {
+ date(annee, 1, 1), date(annee, 5, 1), date(annee, 5, 8),
+ date(annee, 7, 14), date(annee, 8, 15), date(annee, 11, 1),
+ date(annee, 11, 11), date(annee, 12, 25),
+ p + timedelta(days=1), # lundi de Pâques
+ p + timedelta(days=39), # Ascension
+ p + timedelta(days=50), # lundi de Pentecôte
+ }
+
+
+def _categorie_jour(d: date, feries: set[date]) -> str:
+ if d in feries:
+ return "ferie"
+ return {5: "sam", 6: "dim"}.get(d.weekday(), "semaine")
+
+
+def _dans_fenetre(minute: int, debut_min: int, fin_min: int) -> bool:
+ if debut_min <= fin_min:
+ return debut_min <= minute < fin_min
+ return minute >= debut_min or minute < fin_min # fenêtre à cheval sur minuit
+
+
+def _facteur_txt(facteur: float) -> str:
+ return f"{facteur:.2f}".rstrip("0").rstrip(".").replace(".", ",")
+
+
+def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]:
+ return [
+ {
+ "nom": str(r.get("nom") or "?"),
+ "jours": set(r.get("jours") or ()),
+ "debut": hhmm_to_minutes(r.get("debut")),
+ "fin": hhmm_to_minutes(r.get("fin")),
+ "facteur": float(r.get("facteur") or 1),
+ }
+ for r in (facteurs if facteurs else DEFAULT_FACTEURS_ASTREINTE)
+ ]
+
+
+def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | 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 dans les totaux du jour.
+
+ Renvoie {tranches, total_min, total_h, total_pondere_min, total_pondere_h,
+ events: [détail par événement], categories: [compteurs par règle]}.
+ """
+ regles = _regles_astreinte(facteurs)
+ jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
+ feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
+
+ def _regle_du_quart(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] = {}
+ events_out = []
+ for e in events:
+ deb, fin = e.get("debut"), e.get("fin")
+ if not deb or not fin:
+ continue
+ deb_min, fin_min = hhmm_to_minutes(deb), hhmm_to_minutes(fin)
+ duree = fin_min - deb_min if fin_min >= deb_min else 24 * 60 - deb_min + fin_min
+ a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
+ quarts = list(range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
+ a0 + duree, PAS_ASTREINTE_MIN))
+ for qm in quarts:
+ quart_regles.setdefault(qm, _regle_du_quart(qm))
+ ev_cats: dict[str, dict] = {}
+ for qm in quarts:
+ regle = quart_regles.get(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
+ 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)}
+ 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": len(quarts), "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
+ 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))})
+ 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_pondere = sum(c["pondere_min"] for c in categories)
+ return {
+ "tranches": len(quart_regles),
+ "total_min": total_min,
+ "total_h": minutes_to_hhmm(total_min),
+ "total_pondere_min": total_pondere,
+ "total_pondere_h": minutes_to_hhmm(total_pondere),
+ "events": events_out,
+ "categories": categories,
+ }
+
+
def retour_apres_repos(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None:
"""Heure de retour au bureau après les 11h de repos consécutif qui suivent la
dernière activité d'astreinte.
- Seule l'heure de FIN de cette activité est saisie, sur le jour d'astreinte ou
- son lendemain. La fin peut tomber le matin (l'intervention a dépassé minuit) :
- saisie sur le jour d'astreinte, une fin matinale désigne la fin de SA nuit ;
- saisie sur le lendemain, une fin matinale désigne la fin de la nuit de la
- veille. Le retour s'applique au jour où se termine le repos : fin 23:30 →
- retour 10:30 le lendemain ; fin 06:00 → retour 17:00. Renvoie des minutes
- depuis minuit, ou None si rien ne contraint le retour au-delà de l'ouverture."""
+ Chaque activité d'astreinte est un événement {debut, fin} saisi sur le jour de
+ son début (les événements remplacent l'ancienne fin unique). fin < debut
+ désigne une intervention à cheval sur minuit, dont la fin (heure du lendemain)
+ termine l'activité. Le retour s'applique au jour où se termine le repos : fin
+ 23:30 → retour 10:30 le lendemain ; 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 [])
- matin_debut = hhmm_to_minutes(plages["matin_debut"])
veille = _veille(date_str)
cands = []
- def _fin_activite(date_op: str) -> int | None:
+ def _fins(date_op: str):
row = p_by_date.get(date_op) or {}
- fin = row.get("astreinte_fin")
- return hhmm_to_minutes(fin) if fin else None
+ for e in row.get("astreinte_events") or []:
+ deb, fin = e.get("debut"), e.get("fin")
+ if deb and fin:
+ yield hhmm_to_minutes(deb), hhmm_to_minutes(fin)
- # Fin saisie sur la veille : fin de soirée → repos terminé aujourd'hui ; fin
- # matinale sur un jour d'astreinte → la nuit a dépassé minuit, le repos se
- # termine aussi aujourd'hui.
- if jour_astreinte_qualifie(veille, astreintes):
- fin = _fin_activite(veille)
- if fin is not None:
- if fin + REPOS_QUOTIDIEN_MIN >= 24 * 60:
- cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60)
- elif veille in astreintes and fin < matin_debut:
+ # Événements du jour : activité terminée aujourd'hui, le repos s'achève donc
+ # aujourd'hui (une fin matinale est la fin d'une intervention nocturne du jour).
+ if jour_astreinte_qualifie(date_str, astreintes):
+ for deb, fin in _fins(date_str):
+ if fin >= deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60:
+ cands.append(fin + REPOS_QUOTIDIEN_MIN)
+ # Événements de la veille : fin de soirée → le repos se termine aujourd'hui ;
+ # fin après minuit (fin < deb) → l'activité s'est achevée aujourd'hui à fin,
+ # le repos s'achève donc aujourd'hui.
+ if jour_astreinte_qualifie(veille, astreintes):
+ for deb, fin in _fins(veille):
+ if fin >= deb and fin + REPOS_QUOTIDIEN_MIN >= 24 * 60:
+ cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60)
+ if fin < deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60:
cands.append(fin + REPOS_QUOTIDIEN_MIN)
- # Fin matinale saisie sur le jour même (lendemain d'astreinte) : la nuit de
- # la veille s'est terminée après minuit.
- if veille in astreintes:
- fin = _fin_activite(date_str)
- if fin is not None and fin < matin_debut:
- cands.append(fin + REPOS_QUOTIDIEN_MIN)
if not cands:
return None
best = max(cands)
# Un repos qui se termine avant l'ouverture ne contraint rien
- return best if best > matin_debut else None
+ return best if best > hhmm_to_minutes(plages["matin_debut"]) else None
def heures_travaillees(p: dict, retour_min: int | None = None) -> int:
@@ -178,7 +335,7 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int,
return max(0, du)
-def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None) -> dict:
+def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None) -> dict:
"""Compute totals for a list of pointages (one week)."""
plages = {**DEFAULT_PLAGES, **(plages or {})}
astreintes = set(astreintes or [])
@@ -193,12 +350,21 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
total_du = 0
jours = []
p_by_date = {p["date"]: p for p in pointages}
+ # Cumuls astreinte de la semaine, suivis à part : les heures astreinte ne
+ # comptent ni dans travaille ni dans du (compteurs séparés pondérés).
+ as_jours = []
+ as_total_min = 0
+ as_total_pondere_min = 0
+ as_cats: dict[str, dict] = {}
for p in pointages:
conges_date = {c["type"] for c in conges if c["date"] == p["date"]}
# 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
+ events = p.get("astreinte_events") or []
+ dec = decompte_astreinte(p["date"], events, facteurs) if (jour_astreinte_qualifie(p["date"], astreintes) and events) else None
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages)
travaille = heures_travaillees(p, retour_min)
du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min)
@@ -223,9 +389,37 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"aprem_nominal_min": aprem_nom,
"astreinte": p["date"] in astreintes,
"astreinte_qualifiee": jour_astreinte_qualifie(p["date"], astreintes),
+ "astreinte_min": dec["total_min"] if dec else 0,
+ "astreinte_h": dec["total_h"] if dec else "",
+ "astreinte_pondere_min": dec["total_pondere_min"] if dec else 0,
"retour_min": retour_min,
"retour": minutes_to_time(retour_min) if retour_min is not None else None,
})
+ if dec and dec["total_min"] > 0:
+ as_jours.append({"date": p["date"], "events": dec["events"]})
+ 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["min"] += c["min"]
+ fc["pondere_min"] += c["pondere_min"]
+
+ # Catégories astreinte fusionnées sur la semaine, dans l'ordre des règles.
+ ordre_as = [r["nom"] for r in _regles_astreinte(facteurs)] + ["Autre"]
+ as_categories = []
+ for nom in sorted(as_cats, key=lambda n: ordre_as.index(n) if n in ordre_as else len(ordre_as)):
+ c = as_cats[nom]
+ as_categories.append({
+ "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"],
+ "pondere_h": minutes_to_hhmm(c["pondere_min"]),
+ })
# Mark each day as complete (all non-congé slots filled, or day is a congé/weekend).
# Une demi-journée entièrement couverte par le repos de 11h (retour après sa
@@ -403,4 +597,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"slots_restants": slots_restants,
"moyenne_extra_min": (extra_total // slots_restants) if slots_restants else 0,
"moyenne_extra": minutes_to_hhmm((extra_total // slots_restants) if slots_restants else 0),
+ "astreinte": {
+ "actif": as_total_min > 0,
+ "total_min": as_total_min,
+ "total_h": minutes_to_hhmm(as_total_min),
+ "total_pondere_min": as_total_pondere_min,
+ "total_pondere_h": minutes_to_hhmm(as_total_pondere_min),
+ "categories": as_categories,
+ "jours": as_jours,
+ },
}
diff --git a/app/export.py b/app/export.py
index b812888..849dc24 100644
--- a/app/export.py
+++ b/app/export.py
@@ -16,7 +16,7 @@ from models import (
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
HEADERS = [
"Date", "Jour", "Matin entrée", "Matin sortie", "Aprem entrée", "Aprem sortie",
- "Travaillé", "Dû", "Δ jour", "Congé", "Astreinte", "Retour",
+ "Travaillé", "Dû", "Δ jour", "Congé", "Astreinte", "Astreinte pondérée", "Retour",
]
@@ -61,8 +61,8 @@ def _congé_label(cg: set) -> str:
def _astreinte_label(jour: dict) -> str:
- if jour.get("astreinte_fin"):
- return f"fin {jour['astreinte_fin']}"
+ if jour.get("astreinte_min"):
+ return jour["astreinte_h"]
return "Astreinte" if jour.get("astreinte") else ""
@@ -76,18 +76,20 @@ def _jour_row(jour: dict, nom: str) -> TableRow:
("+" if jour["delta_min"] > 0 else "") + jour["delta"],
_congé_label(jour["conge"]),
_astreinte_label(jour),
+ minutes_to_hhmm(jour["astreinte_pondere_min"]) if jour["astreinte_pondere_min"] > 0 else "",
jour.get("retour"),
):
row.addElement(_cell(v))
return row
-def _totals_row(bold: Style, travaille_min: int, du_min: int) -> TableRow:
+def _totals_row(bold: Style, travaille_min: int, du_min: int,
+ as_min: int = 0, as_pondere_min: int = 0) -> TableRow:
row = TableRow()
for v in ("Total", "", "", "", "", "",
minutes_to_hhmm(travaille_min), minutes_to_hhmm(du_min),
("+" if travaille_min - du_min > 0 else "") + minutes_to_hhmm(travaille_min - du_min),
- "", "", ""):
+ "", minutes_to_hhmm(as_min), minutes_to_hhmm(as_pondere_min), ""):
row.addElement(_cell(v, bold))
return row
@@ -121,7 +123,9 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes:
for jour, nom in zip(result["jours"], JOURS_FR):
table.addElement(_jour_row(jour, nom))
table.addElement(_totals_row(bold, sum(j["travaille_min"] for j in result["jours"]),
- sum(j["du_min"] for j in result["jours"])))
+ sum(j["du_min"] for j in result["jours"]),
+ sum(j["astreinte_min"] for j in result["jours"]),
+ sum(j["astreinte_pondere_min"] for j in result["jours"])))
doc.spreadsheet.addElement(table)
buf = io.BytesIO()
@@ -183,7 +187,9 @@ def export_annee_ods(year: int, user_id: str) -> bytes:
table.addElement(_header_row(bold))
for jour, nom in zip(result["jours"], JOURS_FR):
table.addElement(_jour_row(jour, nom))
- table.addElement(_totals_row(bold, travaille_min, du_min))
+ table.addElement(_totals_row(bold, travaille_min, du_min,
+ sum(j["astreinte_min"] for j in result["jours"]),
+ sum(j["astreinte_pondere_min"] for j in result["jours"])))
doc.spreadsheet.addElement(table)
buf = io.BytesIO()
diff --git a/app/main.py b/app/main.py
index cf83401..8c0acf7 100644
--- a/app/main.py
+++ b/app/main.py
@@ -27,11 +27,12 @@ from models import (
load_auth, save_auth, find_user_by_reset_token,
load_plages, save_plages, heures_jour_min,
append_log, load_logs, load_or_create_secret,
+ load_facteurs_astreinte,
)
from calcul import (
compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues,
heures_travaillees, jour_astreinte_qualifie, exclusion_retour,
- nominaux_du_jour, retour_apres_repos,
+ nominaux_du_jour, retour_apres_repos, decompte_astreinte,
)
from stats import compute_all_stats
from export import export_semaine_ods, export_annee_ods
@@ -307,7 +308,7 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id):
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
- result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id))
+ result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id), load_facteurs_astreinte())
result["heures_jour"] = minutes_to_hhmm(h_jour)
return {"result": result}
@@ -331,6 +332,11 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
du_full = du + matin_ex + aprem_ex
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex)
delta = trav - du
+ # Décompte astreinte du jour (compteur séparé, hors heures de semaine) :
+ # seulement si des événements sont stockés et que le jour est qualifié.
+ events = p.get("astreinte_events") or []
+ dec = (decompte_astreinte(date, events, load_facteurs_astreinte())
+ if events and jour_astreinte_qualifie(date, astreintes) else None)
return {
**p,
"travaille_min": trav, "du_min": du, "delta_min": delta,
@@ -345,6 +351,8 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
"astreinte_qualifiee": jour_astreinte_qualifie(date, astreintes),
"retour_min": retour_min,
"retour": minutes_to_time(retour_min) if retour_min is not None else None,
+ "astreinte_min": dec["total_min"] if dec else 0,
+ "astreinte_h": dec["total_h"] if dec else "",
}
@@ -443,22 +451,42 @@ def _btns_html(date, jour):
)
+def _as_event_html(date, ev):
+ """Une ligne d'intervention d'astreinte : début → fin, description, suppression."""
+ return (
+ f'
'
+ f''
+ f'→'
+ f''
+ f''
+ f''
+ f'
'
+ )
+
+
def _as_block_html(date, jour):
- """Bloc astreinte d'une ligne : heure de fin de la dernière activité (hors
- horaires), qui déclenche les 11h de repos et décale d'autant l'heure de
- retour. Rendu sur les jours qualifiés (astreinte ou lendemain) ; div vide
- ailleurs, cible du swap."""
+ """Bloc astreinte d'une ligne : événements d'intervention (début/fin, nombre
+ libre, description), qui déclenchent les 11h de repos et décalent l'heure de
+ retour, plus le décompte du jour (compteur séparé, hors heures de semaine).
+ Rendu sur les jours qualifiés (astreinte ou lendemain) ; div vide ailleurs."""
if not jour.get("astreinte_qualifiee"):
return f''
+ events = jour.get("astreinte_events") or []
+ 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''
f'retour ≥ {_e(jour["retour"])}')
+ total = ""
+ if jour.get("astreinte_min"):
+ total = (f''
+ f'astreinte : {_e(jour["astreinte_h"])}')
return (
f'
'
- f'Fin activité'
- f''
+ f'
{rows}
'
+ f''
f'{hint}'
+ f'{total}'
f'
'
)
@@ -692,7 +720,7 @@ def semaine(request: Request, year: int, week: int):
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
- result = compute_week(pointages, conges, h_jour, plages, load_astreintes(user_id))
+ result = compute_week(pointages, conges, h_jour, plages, load_astreintes(user_id), load_facteurs_astreinte())
prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1)
next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
@@ -718,7 +746,8 @@ def save_pointage(
request: Request, date: str,
matin_entree: str = Form(""), matin_sortie: str = Form(""),
aprem_entree: str = Form(""), aprem_sortie: str = Form(""),
- astreinte_fin: str = Form(""),
+ astreinte_debut: list[str] = Form([]), astreinte_fin: list[str] = Form([]),
+ astreinte_desc: list[str] = Form([]),
):
user_id = _get_user(request)
if not user_id:
@@ -734,18 +763,23 @@ def save_pointage(
iso = d.isocalendar()
raw = load_week_pointages(iso.year, iso.week, user_id)
p_by_date = {p["date"]: p for p in raw}
- # La fin d'activité n'est affichée (donc postée) que les jours qualifiés
- # (astreinte ou lendemain) ; ailleurs on conserve la valeur déjà saisie au
- # lieu de l'écraser à vide.
+ # Les événements d'astreinte ne sont affichés (donc postés) que les jours
+ # qualifiés (astreinte ou lendemain) ; ailleurs on conserve ceux déjà saisis
+ # au lieu de les écraser à vide. Les paires début/fin invalides sont ignorées.
if jour_astreinte_qualifie(date, set(load_astreintes(user_id))):
- champs_astreinte = {"astreinte_fin": clean(astreinte_fin)}
+ events = []
+ for deb, fin, desc in zip(astreinte_debut, astreinte_fin, astreinte_desc):
+ deb, fin = clean(deb), clean(fin)
+ if deb and fin:
+ events.append({"debut": deb, "fin": fin,
+ "desc": (desc or "").strip()[:200] or None})
else:
- champs_astreinte = {"astreinte_fin": p_by_date.get(date, {}).get("astreinte_fin")}
+ events = p_by_date.get(date, {}).get("astreinte_events")
p_by_date[date] = {
"date": date,
"matin_entree": clean(matin_entree), "matin_sortie": clean(matin_sortie),
"aprem_entree": clean(aprem_entree), "aprem_sortie": clean(aprem_sortie),
- **champs_astreinte,
+ "astreinte_events": events,
}
save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]), user_id)
conges = load_conges(user_id)
diff --git a/app/models.py b/app/models.py
index d8b3f85..7264814 100644
--- a/app/models.py
+++ b/app/models.py
@@ -4,7 +4,7 @@ import secrets
from datetime import datetime
from pathlib import Path
-from calcul import DEFAULT_PLAGES
+from calcul import DEFAULT_PLAGES, DEFAULT_FACTEURS_ASTREINTE
DATA_DIR = Path("/data")
CONFIG_FILE = DATA_DIR / "config.json"
@@ -24,6 +24,7 @@ DEFAULT_CONFIG = {
"use_tls": True,
},
"plages": dict(DEFAULT_PLAGES),
+ "astreinte_facteurs": list(DEFAULT_FACTEURS_ASTREINTE),
}
# ── User path helpers ──────────────────────────────────────────────────────────
@@ -180,6 +181,15 @@ def toggle_astreinte(date: str, user_id: str = ""):
save_astreintes(dates, user_id)
+def load_facteurs_astreinte() -> list[dict]:
+ """Règles globales de comptage des heures d'astreinte (type de jour × fenêtre
+ horaire × facteur) ; défauts cohérents si absentes ou invalides dans la config."""
+ facteurs = load_config().get("astreinte_facteurs")
+ if facteurs and isinstance(facteurs, list) and all(isinstance(f, dict) for f in facteurs):
+ return facteurs
+ return DEFAULT_FACTEURS_ASTREINTE
+
+
# ── Auth (email + password) ────────────────────────────────────────────────────
def _auth_file(user_id: str) -> Path:
diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html
index de03472..7eb2e37 100644
--- a/app/templates/_ligne.html
+++ b/app/templates/_ligne.html
@@ -43,11 +43,37 @@
{% if j.astreinte_qualifiee %}
- Fin activité
-
+
+ {% for ev in j.astreinte_events or [] %}
+
+
+ →
+
+
+
+
+ {% else %}
+
+
+ →
+
+
+
+
+ {% endfor %}
+
+
{% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %}
+ {% if j.astreinte_min %}astreinte : {{ j.astreinte_h }}{% endif %}
{% else %}
diff --git a/app/templates/_soldes.html b/app/templates/_soldes.html
index 9eee529..ba191cf 100644
--- a/app/templates/_soldes.html
+++ b/app/templates/_soldes.html
@@ -43,4 +43,27 @@
complétez ou corrigez les saisies, ou rattrapez sur une autre semaine.
{% endif %}
+ {% if result.astreinte and result.astreinte.actif %}
+
+
Astreinte — tranches de 15 min entamées, comptées entières (compteur séparé, hors heures de semaine)
+ {% for j in result.astreinte.jours %}
+
+ {{ j.date[8:] }}/{{ j.date[5:7] }}
+ {% for ev in j.events %}
+
+ {{ ev.debut }} → {{ ev.fin }}
+ {% if ev.desc %}{{ ev.desc }}{% endif %}
+ {{ ev.tranches }} tr. = {{ ev.min_h }}
+ {% for d in ev.details %}{{ d.nom }} ×{{ d.facteur_txt }}{% if not loop.last %} + {% endif %}{% endfor %}
+ pondéré → {{ ev.pondere_h }}
+
+ {% endfor %}
+
+ {% endfor %}
+
+ {% for c in result.astreinte.categories %}{{ c.nom }} : {{ c.min_h }} ×{{ c.facteur_txt }} = {{ c.pondere_h }}{% endfor %}
+ total {{ result.astreinte.total_h }} → {{ result.astreinte.total_pondere_h }} pondérées
+
+
+ {% endif %}
diff --git a/app/templates/base.html b/app/templates/base.html
index 0c94e15..d4ea039 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -455,6 +455,89 @@
color: var(--accent);
white-space: nowrap;
}
+ .as-events { display: flex; flex-direction: column; gap: 3px; width: 100%; }
+ .as-event { display: flex; align-items: center; gap: 4px; }
+ .as-desc {
+ flex: 1;
+ min-width: 60px;
+ border: 1px solid var(--rule);
+ border-radius: 0;
+ padding: .25rem .3rem;
+ font-size: 11px;
+ font-family: var(--sans);
+ color: var(--text);
+ background: transparent;
+ transition: border-color .15s;
+ }
+ .as-desc:hover { border-color: var(--muted); }
+ .as-desc:focus { outline: none; border-color: var(--accent); background: #fff; }
+ .as-del {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px; height: 18px;
+ border: 1px solid var(--rule);
+ background: transparent;
+ cursor: pointer;
+ font-family: var(--mono);
+ font-size: 10px;
+ color: var(--muted);
+ flex-shrink: 0;
+ transition: border-color .15s, color .15s;
+ }
+ .as-del:hover { border-color: var(--neg); color: var(--neg); }
+ .as-add {
+ border: none;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+ font-family: var(--mono);
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: var(--accent-2);
+ white-space: nowrap;
+ }
+ .as-add:hover { text-decoration: underline; }
+ .as-total {
+ font-family: var(--mono);
+ font-size: 10px;
+ color: var(--accent-2);
+ white-space: nowrap;
+ }
+
+ /* ── Décompte astreinte (section soldes) ── */
+ .as-decompte {
+ border: 1px solid var(--rule);
+ background: var(--surface);
+ margin-top: .6rem;
+ padding: .55rem .75rem;
+ font-size: 12px;
+ }
+ .as-decompte-title {
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: .1em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin-bottom: .35rem;
+ }
+ .as-ev { display: flex; flex-wrap: wrap; gap: .45rem; align-items: baseline; padding: .12rem 0; }
+ .as-ev-h { font-family: var(--mono); }
+ .as-ev-desc { color: var(--muted); font-style: italic; }
+ .as-ev-q { font-family: var(--mono); color: var(--text); }
+ .as-ev-cat, .as-cat { color: var(--accent); font-family: var(--mono); font-size: 11px; }
+ .as-ev-pond { font-family: var(--mono); color: var(--accent-2); }
+ .as-jour-date { font-family: var(--mono); font-weight: 500; margin-right: .4rem; }
+ .as-totaux {
+ border-top: 1px dashed var(--rule);
+ margin-top: .4rem;
+ padding-top: .4rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: .75rem;
+ }
+ .as-grand { font-family: var(--mono); font-weight: 500; }
/* ── Tableau hebdo en cartes empilées sous 700px (évite le scroll horizontal) ── */
@media (max-width: 700px) {
@@ -519,6 +602,7 @@
.pointage-table .btn-cg.btn-jour { width: 32px; height: 32px; }
.as-block { padding: .4rem .75rem .55rem; }
.as-block .time-input { width: 92px; }
+ .as-desc { font-size: 13px; padding: .4rem .4rem; }
}
/* ── Historique ── */
diff --git a/app/templates/semaine.html b/app/templates/semaine.html
index 253b632..95c02c0 100644
--- a/app/templates/semaine.html
+++ b/app/templates/semaine.html
@@ -366,4 +366,21 @@ document.addEventListener('input', function(e) {
}
});
+
+
{% endblock %}
diff --git a/app/tests/test_astreinte.py b/app/tests/test_astreinte.py
index da43c6b..c83fca6 100644
--- a/app/tests/test_astreinte.py
+++ b/app/tests/test_astreinte.py
@@ -1,8 +1,8 @@
-"""Astreinte : fin de dernière activité, repos de 11h et heure de retour décalée."""
+"""Astreinte : événements, décompte par tranches de 15 min, repos de 11h et retour décalé."""
from calcul import (
- compute_week, 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,
- retour_apres_repos, DEFAULT_PLAGES,
+ retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN,
)
import models
@@ -11,12 +11,12 @@ PLAGES = dict(DEFAULT_PLAGES)
def _pointage(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None,
- astreinte_fin=None):
+ astreinte_events=None):
return {
"date": date,
"matin_entree": matin_entree, "matin_sortie": matin_sortie,
"aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie,
- "astreinte_fin": astreinte_fin,
+ "astreinte_events": astreinte_events,
}
@@ -72,44 +72,151 @@ def test_heures_travaillees_entree_chevauchant_le_retour():
assert heures_travaillees(p, hhmm_to_minutes("10:30")) == 90
-# ── Retour au plus tôt après 11h de repos ─────────────────────────────────────
+# ── Retour au plus tôt après 11h de repos (depuis le dernier événement) ───────
-def test_retour_fin_matinee_sur_le_lendemain():
- # fin 02:00 saisie sur le lendemain → retour 13:00 le même jour
- p_by_date = {"2026-07-15": _pointage("2026-07-15", astreinte_fin="02:00")}
+def test_retour_fin_nocturne_sur_le_lendemain():
+ """Event 01:00→02:00 saisi sur le lendemain de l'astreinte → retour 13:00."""
+ p_by_date = {"2026-07-15": _pointage("2026-07-15",
+ astreinte_events=[{"debut": "01:00", "fin": "02:00"}])}
+ # 02:00 + 11h = 13:00
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60
-def test_retour_fin_matinale_sur_le_jour_d_astreinte():
- # fin 06:00 saisie sur le jour d'astreinte (la nuit a dépassé minuit) → retour 17:00 le lendemain
- p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="06:00")}
- assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
+def test_retour_activite_matinale_sur_le_jour_meme():
+ """Event 05:00→06:00 sur le jour d'astreinte : l'activité s'est réellement
+ terminée à 06:00 ce jour-là → repos jusqu'à 17:00 le même jour."""
+ p_by_date = {"2026-07-14": _pointage("2026-07-14",
+ astreinte_events=[{"debut": "05:00", "fin": "06:00"}])}
+ assert retour_apres_repos("2026-07-14", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
def test_retour_fin_du_soir_sur_le_jour_d_astreinte():
- # fin 23:30 sur le jour d'astreinte → repos terminé 10:30 le lendemain
- p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30")}
+ """Event 21:00→23:30 sur le jour d'astreinte → repos terminé 10:30 le lendemain."""
+ p_by_date = {"2026-07-14": _pointage("2026-07-14",
+ astreinte_events=[{"debut": "21:00", "fin": "23:30"}])}
+ # 23:30 + 11h = 10:30 le lendemain
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 30
+def test_retour_event_a_cheval_sur_minuit_saisi_la_veille():
+ """Event 23:00→01:00 (fin < début) saisi la veille : fin réelle 01:00 le jour
+ même → 01:00 + 11h = 12:00."""
+ p_by_date = {"2026-07-14": _pointage("2026-07-14",
+ astreinte_events=[{"debut": "23:00", "fin": "01:00"}])}
+ assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60
+
+
+def test_retour_nuit_d_astreinte_finissant_au_matin():
+ """Event 23:00→06:00 (à cheval sur minuit) sur le jour d'astreinte : fin réelle
+ 06:00 le lendemain → retour 17:00 le lendemain."""
+ p_by_date = {"2026-07-14": _pointage("2026-07-14",
+ astreinte_events=[{"debut": "23:00", "fin": "06:00"}])}
+ assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60
+
+
def test_retour_la_plus_tardive_des_deux_fins_gagne():
- # fins saisies sur le jour d'astreinte (23:30) ET le lendemain (02:00) : la plus tardive gagne
+ """Fins veille 23:30 (retour 10:30) ET jour 02:00 (retour 13:00) : la plus tardive gagne."""
p_by_date = {
- "2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30"),
- "2026-07-15": _pointage("2026-07-15", astreinte_fin="02:00"),
+ "2026-07-14": _pointage("2026-07-14", astreinte_events=[{"debut": "21:00", "fin": "23:30"}]),
+ "2026-07-15": _pointage("2026-07-15", astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60
+def test_retour_event_sans_astreinte_ignore():
+ p_by_date = {"2026-07-14": _pointage("2026-07-14",
+ astreinte_events=[{"debut": "21:00", "fin": "23:30"}])}
+ assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, []) is None
+
+
def test_retour_fin_tot_ne_contraint_rien():
- # fin 17:30 → repos terminé 04:30, avant l'ouverture : pas de contrainte
- p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="17:30")}
+ """Event 17:15→17:30 → repos terminé 04:30, avant l'ouverture : pas de contrainte."""
+ p_by_date = {"2026-07-14": _pointage("2026-07-14",
+ astreinte_events=[{"debut": "17:15", "fin": "17:30"}])}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None
-def test_retour_fin_sans_astreinte_ignoree():
- p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30")}
- assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, []) is None
+# ── Décompte astreinte : tranches de 15 min entamées, 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."""
+ 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["categories"][0]["nom"] == "Nuit" # lundi 22:xx → Nuit ×1,5
+ assert d["total_pondere_min"] == 45 # 30 × 1,5
+
+
+def test_decompte_arrondi_des_bornes():
+ """09:50→10:05 touche les tranches 09:45 et 10:00 : 2 tranches 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["categories"][0]["nom"] == "Journée" # lundi en journée ×1
+ assert d["total_pondere_min"] == 30
+
+
+def test_decompte_evenement_a_cheval_sur_minuit():
+ """Mardi 14/07 (férié), 23:00→01:00 : 8 tranches, tout en Nuit ×1,5."""
+ d = decompte_astreinte("2026-07-14", [{"debut": "23:00", "fin": "01:00"}])
+ assert d["tranches"] == 8 # 23:00→01:00 = 120 min = 8 × 15
+ assert d["total_min"] == 120
+ assert d["categories"][0]["nom"] == "Nuit"
+ assert d["total_pondere_min"] == 180 # 120 × 1,5
+
+
+def test_decompte_jour_ferie_en_journee():
+ """14 juillet : la journée compte en « Dimanche / férié » ×2."""
+ d = decompte_astreinte("2026-07-14", [{"debut": "10:00", "fin": "12:00"}])
+ assert d["total_min"] == 120 # 2h = 8 tranches
+ assert d["categories"][0]["nom"] == "Dimanche / férié"
+ assert d["total_pondere_min"] == 240 # 120 × 2
+ assert d["total_pondere_h"] == "4h00"
+
+
+def test_decompte_vendredi_vers_samedi_reste_en_nuit():
+ """23:00→02:00 le vendredi : 12 tranches, toutes en Nuit (même après minuit)."""
+ d = decompte_astreinte("2026-07-17", [{"debut": "23:00", "fin": "02:00"}])
+ assert d["tranches"] == 12 # 180 min
+ assert d["total_min"] == 180
+ assert [c["nom"] for c in d["categories"]] == ["Nuit"]
+ assert d["total_pondere_min"] == 270 # 180 × 1,5
+
+
+def test_decompte_chevauchement_dedouble():
+ """10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent
+ qu'une fois dans le total du jour, chaque event garde son propre détail."""
+ 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"] == 2 # 10:10 aligné sur la tranche 10:00
+ assert d["events"][1]["min"] == 30
+ assert d["tranches"] == 2 # union dédoublonnée
+ assert d["total_min"] == 30
+ assert d["categories"][0]["nom"] == "Journée"
+
+
+def test_decompte_event_incomplet_ignore():
+ """Un événement sans debut ou sans fin est ignoré."""
+ d = decompte_astreinte("2026-07-13", [{"debut": "22:00"}, {"fin": "23:00"}, {}])
+ assert d["tranches"] == 0
+ assert d["total_min"] == 0
+ assert d["events"] == []
+ assert d["categories"] == []
+
+
+def test_decompte_regles_personnalisees():
+ """Une config custom remplace les règles par défaut (fenêtre à cheval sur minuit)."""
+ facteurs = [{"nom": "Garde", "jours": ["semaine"], "debut": "18:00", "fin": "09:00", "facteur": 2}]
+ d = decompte_astreinte("2026-07-13", [{"debut": "20:00", "fin": "21:00"}], facteurs)
+ assert d["total_min"] == 60
+ assert d["categories"][0]["nom"] == "Garde"
+ assert d["total_pondere_min"] == 120
# ── Nominaux ──────────────────────────────────────────────────────────────────
@@ -126,33 +233,65 @@ def test_nominaux_sans_exclusion_reste_la_moitié():
# ── compute_week ──────────────────────────────────────────────────────────────
+def test_compute_week_decompte_absent_des_heures_de_semaine(freeze_today):
+ """Lundi et jeudi complets, mardi astreinte avec event 23:00→01:00, mercredi
+ vide : les heures d'astreinte n'entrent jamais dans total_travaille/total_du,
+ seulement dans result["astreinte"]."""
+ pointages = [
+ _pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
+ _pointage("2026-07-14", astreinte_events=[{"debut": "23:00", "fin": "01:00"}]),
+ _pointage("2026-07-15"),
+ _pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
+ _pointage("2026-07-17"),
+ ]
+ result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
+
+ # seul le pointé compte : 2 × 468 = 15h36, astreinte exclue
+ assert result["total_travaille"] == "15h36"
+
+ ast = result["astreinte"]
+ assert ast["actif"] is True
+ assert ast["total_min"] == 120 # 23:00→01:00 = 8 tranches
+ assert ast["total_h"] == "2h00"
+ assert ast["total_pondere_min"] == 180 # Nuit ×1,5
+ assert ast["total_pondere_h"] == "3h00"
+ assert ast["categories"][0]["nom"] == "Nuit"
+ assert any(j["date"] == "2026-07-14" for j in ast["jours"])
+
+ mardi = result["jours"][1]
+ assert mardi["astreinte_min"] == 120
+ assert mardi["astreinte_h"] == "2h00"
+ assert mardi["astreinte_pondere_min"] == 180
+
+
def test_compute_week_fin_nocturne_decale_le_decompte_et_les_cibles(freeze_today):
- """Mardi astreinte, activité terminée mercredi 02:00 (fin saisie sur le
- lendemain) : retour mercredi 13:00, présence du matin retirée de la semaine,
- aucune cible matin, après-midi 13:30→18:18 (nominal 288 = 234 + report)."""
+ """Mardi astreinte, event 01:00→02:00 saisi sur le mercredi (lendemain) :
+ retour mercredi 13:00, présence du matin retirée de la semaine, aucune cible
+ matin, après-midi 13:30→18:18 (nominal 288 = 234 + report)."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
- _pointage("2026-07-15", astreinte_fin="02:00"),
+ _pointage("2026-07-15", astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
_pointage("2026-07-16"),
_pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
mercredi = result["jours"][2]
- assert mercredi["du_min"] == 468 - 180
- assert mercredi["retour"] == "13:00"
+ assert mercredi["du_min"] == 468 - 180 # matin 09:00→12:00 exclu par le retour 13:00
+ assert mercredi["retour"] == "13:00" # 02:00 + 11h
assert mercredi["cible_matin"] is None
assert mercredi["cible_aprem"] == "18:18"
- assert result["total_du"] == "36h00" # 39h - 3h de présence retirées
+ assert result["total_du"] == "36h00" # 39h - 3h de présence retirées
-def test_compute_week_fin_matinale_sur_jour_d_astreinte(freeze_today):
- """Fin 06:00 saisie directement sur le mardi astreinte : le retour 17:00
- s'applique au mercredi (matin et après-midi exclus jusqu'à 17:00)."""
+def test_compute_week_nuit_finissant_au_matin(freeze_today):
+ """Event 23:00→06:00 (à cheval sur minuit) saisi sur le mardi astreinte : le
+ retour 17:00 s'applique au mercredi (matin et après-midi exclus jusqu'à 17:00)."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
- _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54", astreinte_fin="06:00"),
+ _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54",
+ astreinte_events=[{"debut": "23:00", "fin": "06:00"}]),
_pointage("2026-07-15"),
_pointage("2026-07-16"),
_pointage("2026-07-17"),
@@ -167,30 +306,38 @@ def test_compute_week_fin_matinale_sur_jour_d_astreinte(freeze_today):
# aprem démarre à 17:00, nominal 108 (54 matin + 54 aprem) → 18:48
assert mercredi["cible_aprem_entree"] == "17:00"
assert mercredi["cible_aprem"] == "18:48"
+ # décompte : 23:00→06:00 = 28 tranches = 7h en Nuit ×1,5
+ assert result["jours"][1]["astreinte_min"] == 420
+ assert result["astreinte"]["total_pondere_min"] == 630
-def test_compute_week_fin_ignoree_hors_jour_qualifie(freeze_today):
- """Une fin d'activité saisie un jour non qualifié (pas d'astreinte la veille)
- ne change rien au décompte."""
+def test_compute_week_event_ignore_hors_jour_qualifie(freeze_today):
+ """Un event saisi un jour non qualifié (pas d'astreinte) ne change rien au
+ décompte ni au décompte d'astreinte."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
- _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54", astreinte_fin="02:00"),
+ _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54",
+ astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
_pointage("2026-07-15"),
_pointage("2026-07-16"),
_pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, [])
- assert result["jours"][1]["du_min"] == HEURES_JOUR_MIN
- assert result["jours"][1]["retour_min"] is None
+ mardi = result["jours"][1]
+ assert mardi["du_min"] == HEURES_JOUR_MIN
+ assert mardi["retour_min"] is None
+ assert mardi["astreinte_min"] == 0
+ assert result["astreinte"]["actif"] is False
def test_compute_week_arrivee_visee_ne_precede_jamais_le_retour(freeze_today):
- """Fin 23:30 mardi → retour mercredi 10:30 : arrivée cible du matin 10:30,
- sortie 12:00, et le nominal qui ne tient pas (234-90=144 → 54 manquantes)
- bascule sur l'après-midi du même jour."""
+ """Event mardi 21:00→23:30 → retour mercredi 10:30 : arrivée cible du matin
+ 10:30, sortie 12:00, et le nominal qui ne tient pas (234-90=144 → 54
+ manquantes) bascule sur l'après-midi du même jour."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
- _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54", astreinte_fin="23:30"),
+ _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54",
+ astreinte_events=[{"debut": "21:00", "fin": "23:30"}]),
_pointage("2026-07-15"),
_pointage("2026-07-16"),
_pointage("2026-07-17"),
@@ -206,14 +353,16 @@ def test_compute_week_arrivee_visee_ne_precede_jamais_le_retour(freeze_today):
def test_compute_week_demi_journee_entierement_en_repos_compte_complete(freeze_today):
- """Avec un quota de 6h/jour (180 par demi-journée), une fin à 02:00 (retour
- 13:00) couvre toute la plage du matin : plus rien à y remplir, la demi-journée
- est complète d'office et l'absence de pointage matin ne casse pas le cumul."""
+ """Avec un quota de 6h/jour (180 par demi-journée), un retour 13:00 (event
+ 01:00→02:00 sur le mercredi, mardi astreinte) couvre toute la plage du matin :
+ plus rien à y remplir, la demi-journée est complète d'office et l'absence de
+ pointage matin ne casse pas le cumul."""
heures_jour = 360
pointages = [
_pointage("2026-07-13", "09:00", "12:00", "13:30", "16:30"),
_pointage("2026-07-14", "09:00", "12:00", "13:30", "16:30"),
- _pointage("2026-07-15", aprem_entree="13:30", aprem_sortie="16:30", astreinte_fin="02:00"),
+ _pointage("2026-07-15", aprem_entree="13:30", aprem_sortie="16:30",
+ astreinte_events=[{"debut": "01:00", "fin": "02:00"}]),
_pointage("2026-07-16", "09:00", "12:00", "13:30", "16:30"),
_pointage("2026-07-17", "09:00", "12:00", "13:30", "16:30"),
]
@@ -233,3 +382,8 @@ def test_toggle_astreinte_persiste():
assert models.load_astreintes("u-astreinte") == ["2026-07-14"]
models.toggle_astreinte("2026-07-14", "u-astreinte")
assert models.load_astreintes("u-astreinte") == []
+
+
+def test_load_facteurs_astreinte_defaut_si_absent():
+ facteurs = models.load_facteurs_astreinte()
+ assert any(f["nom"] == "Nuit" for f in facteurs)
diff --git a/data/config.example.json b/data/config.example.json
index faa566d..9747c2f 100644
--- a/data/config.example.json
+++ b/data/config.example.json
@@ -1,6 +1,12 @@
{
"heures_semaine": 39.0,
"jours_par_semaine": 5,
+ "astreinte_facteurs": [
+ {"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": "Samedi", "jours": ["sam"], "debut": "08:00", "fin": "20:00", "facteur": 1.5},
+ {"nom": "Dimanche / férié", "jours": ["dim", "ferie"], "debut": "08:00", "fin": "20:00", "facteur": 2.0}
+ ],
"log_level": "DEBUG",
"allowed_email_domain": "exemple.fr",
"base_url": "",