Décompte les heures d'astreinte par tranches de 15 min dans des compteurs pondérés séparés

La saisie astreinte devient une liste d'événements (début, fin, description,
nombre illimité, fin antérieure au début = intervention à cheval sur minuit).
Ces heures ne comptent jamais dans la semaine : elles sont décomptées à part
par pas de 15 minutes, toute tranche entamée comptant en totalité, et rangées
dans des compteurs selon le jour et l'heure (Nuit ×1,5, Journée semaine ×1,
Samedi ×1,5, Dimanche/férié ×2 ; jours fériés français calculés
automatiquement, règles ajustables dans config.json via astreinte_facteurs).
Le détail par événement et les totaux pondérés s'affichent sous le bandeau de
la semaine, et les exports .ods gagnent les colonnes Astreinte brute et
pondérée. Le retour au bureau (fin du dernier événement + 11h de repos) est
désormais dérivé des événements.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-08 22:26:47 +02:00
parent 065d73e15c
commit c208a17eec
11 changed files with 695 additions and 118 deletions

View File

@ -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,
},
}

View File

@ -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()

View File

@ -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'<div class="as-event">'
f'<input class="time-input" type="time" name="astreinte_debut" data-date="{_e(date)}" value="{_e(ev.get("debut") or "")}">'
f'<span class="time-sep">→</span>'
f'<input class="time-input" type="time" name="astreinte_fin" data-date="{_e(date)}" value="{_e(ev.get("fin") or "")}">'
f'<input type="text" class="as-desc" name="astreinte_desc" data-date="{_e(date)}" maxlength="200" placeholder="description" value="{_e(ev.get("desc") or "")}">'
f'<button type="button" class="as-del" title="Supprimer" onclick="this.closest(\'.as-event\').remove()">×</button>'
f'</div>'
)
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'<div id="as-block-{_e(date)}" hx-swap-oob="true"></div>'
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'<span class="as-hint" title="11h de repos consécutif après la dernière activité">'
f'retour ≥ {_e(jour["retour"])}</span>')
total = ""
if jour.get("astreinte_min"):
total = (f'<span class="as-total" title="Compteur séparé, hors heures de semaine">'
f'astreinte : {_e(jour["astreinte_h"])}</span>')
return (
f'<div id="as-block-{_e(date)}" class="as-block" data-label="Astreinte" hx-swap-oob="true">'
f'<span class="as-label">Fin activité</span>'
f'<input class="time-input" type="time" name="astreinte_fin" data-date="{_e(date)}" value="{_e(jour.get("astreinte_fin") or "")}" title="Heure de fin de la dernière activité (peut être le matin) — 11h de repos ensuite">'
f'<div class="as-events" id="as-events-{_e(date)}">{rows}</div>'
f'<button type="button" class="as-add" onclick="asAddEvent(\'{_e(date)}\')">+ intervention</button>'
f'{hint}'
f'{total}'
f'</div>'
)
@ -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)

View File

@ -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:

View File

@ -43,11 +43,37 @@
</div>
{% if j.astreinte_qualifiee %}
<div id="as-block-{{ j.date }}" class="as-block" data-label="Astreinte">
<span class="as-label">Fin activité</span>
<input class="time-input" type="time" name="astreinte_fin"
data-date="{{ j.date }}" value="{{ j.astreinte_fin or '' }}"
title="Heure de fin de la dernière activité (peut être le matin) — 11h de repos ensuite">
<div class="as-events" id="as-events-{{ j.date }}">
{% for ev in j.astreinte_events or [] %}
<div class="as-event">
<input class="time-input" type="time" name="astreinte_debut"
data-date="{{ j.date }}" value="{{ ev.debut or '' }}">
<span class="time-sep">→</span>
<input class="time-input" type="time" name="astreinte_fin"
data-date="{{ j.date }}" value="{{ ev.fin or '' }}">
<input type="text" class="as-desc" name="astreinte_desc"
data-date="{{ j.date }}" maxlength="200" placeholder="description"
value="{{ ev.desc or '' }}">
<button type="button" class="as-del" title="Supprimer"
onclick="this.closest('.as-event').remove()">×</button>
</div>
{% else %}
<div class="as-event">
<input class="time-input" type="time" name="astreinte_debut"
data-date="{{ j.date }}" value="">
<span class="time-sep">→</span>
<input class="time-input" type="time" name="astreinte_fin"
data-date="{{ j.date }}" value="">
<input type="text" class="as-desc" name="astreinte_desc"
data-date="{{ j.date }}" maxlength="200" placeholder="description" value="">
<button type="button" class="as-del" title="Supprimer"
onclick="this.closest('.as-event').remove()">×</button>
</div>
{% endfor %}
</div>
<button type="button" class="as-add" onclick="asAddEvent('{{ j.date }}')">+ intervention</button>
{% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité">retour ≥ {{ j.retour }}</span>{% endif %}
{% if j.astreinte_min %}<span class="as-total" title="Compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_h }}</span>{% endif %}
</div>
{% else %}
<div id="as-block-{{ j.date }}"></div>

View File

@ -43,4 +43,27 @@
complétez ou corrigez les saisies, ou rattrapez sur une autre semaine.
</div>
{% endif %}
{% if result.astreinte and result.astreinte.actif %}
<div class="as-decompte">
<div class="as-decompte-title">Astreinte — tranches de 15 min entamées, comptées entières (compteur séparé, hors heures de semaine)</div>
{% for j in result.astreinte.jours %}
<div class="as-jour">
<span class="as-jour-date">{{ j.date[8:] }}/{{ j.date[5:7] }}</span>
{% for ev in j.events %}
<div class="as-ev">
<span class="as-ev-h">{{ ev.debut }} → {{ ev.fin }}</span>
{% if ev.desc %}<span class="as-ev-desc">{{ ev.desc }}</span>{% endif %}
<span class="as-ev-q">{{ ev.tranches }} tr. = {{ ev.min_h }}</span>
{% for d in ev.details %}<span class="as-ev-cat">{{ d.nom }} ×{{ d.facteur_txt }}</span>{% if not loop.last %} + {% endif %}{% endfor %}
<span class="as-ev-pond">pondéré → {{ ev.pondere_h }}</span>
</div>
{% endfor %}
</div>
{% endfor %}
<div class="as-totaux">
{% for c in result.astreinte.categories %}<span class="as-cat">{{ c.nom }} : {{ c.min_h }} ×{{ c.facteur_txt }} = {{ c.pondere_h }}</span>{% endfor %}
<span class="as-grand">total {{ result.astreinte.total_h }} → {{ result.astreinte.total_pondere_h }} pondérées</span>
</div>
</div>
{% endif %}
</div>

View File

@ -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 ── */

View File

@ -366,4 +366,21 @@ document.addEventListener('input', function(e) {
}
});
</script>
<script>
// Astreinte — ajoute une ligne d'intervention vierge dans le jour ciblé
window.asAddEvent = function (date) {
var wrap = document.getElementById('as-events-' + date);
if (!wrap) return;
var ev = document.createElement('div');
ev.className = 'as-event';
ev.innerHTML =
'<input class="time-input" type="time" name="astreinte_debut" data-date="' + date + '" value="">' +
'<span class="time-sep">→</span>' +
'<input class="time-input" type="time" name="astreinte_fin" data-date="' + date + '" value="">' +
'<input type="text" class="as-desc" name="astreinte_desc" data-date="' + date + '" maxlength="200" placeholder="description" value="">' +
'<button type="button" class="as-del" title="Supprimer" onclick="this.closest(\'.as-event\').remove()">×</button>';
wrap.appendChild(ev);
};
</script>
{% endblock %}

View File

@ -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)