Ajoute les astreintes : saisie de la fin de dernière activité, retour au bureau décalé de 11h de repos

Un nouveau bouton AS marque un jour d'astreinte. Ce jour et son lendemain,
une saisie « Fin activité » enregistre l'heure de fin de la dernière
intervention hors horaires (elle peut être le matin si la nuit a dépassé
minuit). L'app en déduit l'heure de retour au bureau (fin + 11h de repos
consécutif) et retire du décompte de la semaine toute la présence avant
cette heure : heures dues réduites, présence pointée avant le retour non
comptée, préconisations Fin cible démarrées à l'heure de retour sans
avertissement de retard, demi-journée entièrement en repos considérée
comme remplie. Les exports .ods gagnent les colonnes Astreinte et Retour,
et les stats n'ont plus les retards liés au repos.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-08 22:05:10 +02:00
parent c31fcd28ec
commit 065d73e15c
9 changed files with 665 additions and 58 deletions

View File

@ -179,6 +179,37 @@ 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 :
- **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.
À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le
décompte de la semaine :
- la présence obligatoire avant l'heure de retour est retirée des heures dues
(ex: retour 13:00 → la plage 09:00-12:00 ne compte plus) ;
- une présence pointée avant l'heure de retour ne compte pas dans le travaillé ;
- les préconisations **Fin cible** démarrent à l'heure de retour (arrivée du matin
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`.
---
## Stack ## Stack
`FastAPI` · `HTMX` · `Jinja2` · fichiers JSON · `Docker` `FastAPI` · `HTMX` · `Jinja2` · fichiers JSON · `Docker`

View File

@ -1,5 +1,5 @@
"""Business logic: compute worked hours and expected hours per day/week.""" """Business logic: compute worked hours and expected hours per day/week."""
from datetime import datetime from datetime import datetime, timedelta
DEFAULT_PLAGES = { DEFAULT_PLAGES = {
"matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin "matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin
@ -11,6 +11,8 @@ DEFAULT_PLAGES = {
"depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun) "depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun)
} }
REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau
def hhmm_to_minutes(val: str | None) -> int: def hhmm_to_minutes(val: str | None) -> int:
if not val: if not val:
@ -25,32 +27,132 @@ def minutes_to_hhmm(minutes: int) -> str:
return f"{sign}{minutes // 60}h{minutes % 60:02d}" return f"{sign}{minutes // 60}h{minutes % 60:02d}"
def heures_travaillees(p: dict) -> int: def minutes_to_time(minutes: int) -> str:
return f"{minutes // 60:02d}:{minutes % 60:02d}"
def _veille(date_str: str) -> str:
d = datetime.strptime(date_str, "%Y-%m-%d").date()
return (d - timedelta(days=1)).strftime("%Y-%m-%d")
def jour_astreinte_qualifie(date_str: str, astreintes) -> bool:
"""True si le jour permet la saisie d'opérations d'astreinte : jour d'astreinte,
ou lendemain d'un jour d'astreinte (la nuit qui suit l'astreinte)."""
astreintes = set(astreintes or [])
return date_str in astreintes or _veille(date_str) in astreintes
def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) -> tuple[int, int]:
"""Présence obligatoire (matin, aprem) tombant avant l'heure de retour au bureau,
en minutes : c'est le temps de présence retiré du décompte de la semaine quand le
repos de 11h décale l'arrivée. Les demi-journées en congé ne sont pas décomptées
deux fois."""
if retour_min is None:
return 0, 0
matin_debut = hhmm_to_minutes(plages["matin_debut"])
matin_fin = hhmm_to_minutes(plages["matin_fin"])
aprem_debut = hhmm_to_minutes(plages["aprem_debut"])
aprem_fin = hhmm_to_minutes(plages["aprem_fin"])
matin_ex = 0 if ("matin" in conges_date or "jour" in conges_date) else max(0, min(matin_fin, retour_min) - matin_debut)
aprem_ex = 0 if ("aprem" in conges_date or "jour" in conges_date) else max(0, min(aprem_fin, retour_min) - aprem_debut)
return matin_ex, aprem_ex
def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int) -> tuple[int, int]:
"""Nominaux (attendus) matin et aprem en minutes, après congés et exclusion liée
à l'heure de retour."""
if "jour" in conges_date:
m = a = 0
elif "matin" in conges_date:
m, a = 0, du_full_min
elif "aprem" in conges_date:
m, a = du_full_min, 0
else:
m, a = du_full_min // 2, du_full_min - du_full_min // 2
return max(0, m - matin_excl_min), max(0, a - aprem_excl_min)
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."""
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:
row = p_by_date.get(date_op) or {}
fin = row.get("astreinte_fin")
return hhmm_to_minutes(fin) if fin else None
# 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:
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
def heures_travaillees(p: dict, retour_min: int | None = None) -> int:
"""Return worked minutes for a pointage row. """Return worked minutes for a pointage row.
If a half-day has started but has no exit time yet, count elapsed time up to now If a half-day has started but has no exit time yet, count elapsed time up to now
(today only — past dates with missing exits are treated as 0). (today only — past dates with missing exits are treated as 0). Quand une heure
de retour est posée (repos de 11h après astreinte), la présence avant cette
heure ne compte pas dans les heures de la semaine : les entrées sont décalées
au moins à l'heure de retour.
""" """
today = datetime.today().strftime("%Y-%m-%d") today = datetime.today().strftime("%Y-%m-%d")
now_min = datetime.today().hour * 60 + datetime.today().minute now_min = datetime.today().hour * 60 + datetime.today().minute
def entree_min(v):
m = hhmm_to_minutes(v)
return max(m, retour_min) if retour_min is not None else m
matin = 0 matin = 0
if p["matin_entree"] and p["matin_sortie"]: if p["matin_entree"] and p["matin_sortie"]:
matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - hhmm_to_minutes(p["matin_entree"])) matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - entree_min(p["matin_entree"]))
elif p["matin_entree"] and p.get("date") == today: elif p["matin_entree"] and p.get("date") == today:
matin = max(0, now_min - hhmm_to_minutes(p["matin_entree"])) matin = max(0, now_min - entree_min(p["matin_entree"]))
aprem = 0 aprem = 0
if p["aprem_entree"] and p["aprem_sortie"]: if p["aprem_entree"] and p["aprem_sortie"]:
aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - hhmm_to_minutes(p["aprem_entree"])) aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - entree_min(p["aprem_entree"]))
elif p["aprem_entree"] and p.get("date") == today: elif p["aprem_entree"] and p.get("date") == today:
aprem = max(0, now_min - hhmm_to_minutes(p["aprem_entree"])) aprem = max(0, now_min - entree_min(p["aprem_entree"]))
return matin + aprem return matin + aprem
def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int) -> int: def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int,
"""Return expected minutes for a date, accounting for vacations.""" plages: dict | None = None, retour_min: int | None = None) -> int:
"""Return expected minutes for a date, accounting for vacations. Si une heure de
retour est définie (repos de 11h après astreinte), la présence obligatoire avant
cette heure est retirée du décompte de la semaine."""
d = datetime.strptime(date_str, "%Y-%m-%d").date() d = datetime.strptime(date_str, "%Y-%m-%d").date()
# Weekend = no hours due # Weekend = no hours due
if d.weekday() >= 5: if d.weekday() >= 5:
@ -65,14 +167,21 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int) -> int:
half = heures_jour_min // 2 half = heures_jour_min // 2
if "matin" in conges_date or "aprem" in conges_date: if "matin" in conges_date or "aprem" in conges_date:
return half du = half
else:
du = heures_jour_min
return heures_jour_min if retour_min is not None and plages:
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages)
du -= matin_ex + aprem_ex
return max(0, du)
def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | 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) -> dict:
"""Compute totals for a list of pointages (one week).""" """Compute totals for a list of pointages (one week)."""
plages = {**DEFAULT_PLAGES, **(plages or {})} plages = {**DEFAULT_PLAGES, **(plages or {})}
astreintes = set(astreintes or [])
matin_debut_min = hhmm_to_minutes(plages["matin_debut"]) matin_debut_min = hhmm_to_minutes(plages["matin_debut"])
matin_fin_min = hhmm_to_minutes(plages["matin_fin"]) matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"]) aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
@ -83,10 +192,18 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
total_travaille = 0 total_travaille = 0
total_du = 0 total_du = 0
jours = [] jours = []
p_by_date = {p["date"]: p for p in pointages}
for p in pointages: for p in pointages:
travaille = heures_travaillees(p) conges_date = {c["type"] for c in conges if c["date"] == p["date"]}
du = heures_dues(p["date"], conges, heures_jour_min) # 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)
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)
du_full = du + matin_ex + aprem_ex
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex)
delta = travaille - du delta = travaille - du
total_travaille += travaille total_travaille += travaille
total_du += du total_du += du
@ -98,14 +215,25 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"travaille": minutes_to_hhmm(travaille), "travaille": minutes_to_hhmm(travaille),
"du": minutes_to_hhmm(du), "du": minutes_to_hhmm(du),
"delta": minutes_to_hhmm(delta), "delta": minutes_to_hhmm(delta),
"conge": {c["type"] for c in conges if c["date"] == p["date"]}, "conge": conges_date,
"du_full_min": du_full,
"matin_excl_min": matin_ex,
"aprem_excl_min": aprem_ex,
"matin_nominal_min": matin_nom,
"aprem_nominal_min": aprem_nom,
"astreinte": p["date"] in astreintes,
"astreinte_qualifiee": jour_astreinte_qualifie(p["date"], astreintes),
"retour_min": retour_min,
"retour": minutes_to_time(retour_min) if retour_min is not None else None,
}) })
# Mark each day as complete (all non-congé slots filled, or day is a congé/weekend) # 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
# plage) n'a plus rien à remplir : elle compte comme faite.
for jour in jours: for jour in jours:
cg = jour["conge"] cg = jour["conge"]
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg or jour["matin_nominal_min"] == 0
aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg or jour["aprem_nominal_min"] == 0
jour["is_complete"] = (jour["du_min"] == 0) or (matin_done and aprem_done) jour["is_complete"] = (jour["du_min"] == 0) or (matin_done and aprem_done)
# Cumulative weekly delta — accumulated for all days, but visible only when chain is unbroken # Cumulative weekly delta — accumulated for all days, but visible only when chain is unbroken
@ -146,10 +274,11 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
for jour in jours: for jour in jours:
if jour["date"] != today: if jour["date"] != today:
continue continue
retour = jour.get("retour_min") or 0
if jour.get("matin_entree") and not jour.get("matin_sortie"): if jour.get("matin_entree") and not jour.get("matin_sortie"):
restant_planif += max(0, now_min - hhmm_to_minutes(jour["matin_entree"])) restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["matin_entree"]), retour))
if jour.get("aprem_entree") and not jour.get("aprem_sortie"): if jour.get("aprem_entree") and not jour.get("aprem_sortie"):
restant_planif += max(0, now_min - hhmm_to_minutes(jour["aprem_entree"])) restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["aprem_entree"]), retour))
slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage
for jour in jours: for jour in jours:
@ -160,12 +289,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
du = jour["du_min"] du = jour["du_min"]
if du == 0: if du == 0:
continue continue
matin_nominal = 0 if "matin" in cg else (du if "aprem" in cg else du // 2) matin_nominal = jour["matin_nominal_min"]
aprem_nominal = 0 if "aprem" in cg else (du if "matin" in cg else du - du // 2) aprem_nominal = jour["aprem_nominal_min"]
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg
aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg
if not matin_done: if not matin_done:
debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else arrivee_visee_min # L'arrivée visée ne peut pas précéder l'heure de retour (repos de 11h)
debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else max(arrivee_visee_min, jour["retour_min"] or 0)
reprise = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min reprise = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
cap = max(0, min(matin_fin_min, reprise) - debut) cap = max(0, min(matin_fin_min, reprise) - debut)
# Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi # Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi
@ -175,9 +305,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
aprem_nominal += surplus aprem_nominal += surplus
# Le matin n'est jamais ajusté par le rattrapage hebdomadaire : seule sa # Le matin n'est jamais ajusté par le rattrapage hebdomadaire : seule sa
# capacité avant la pause de midi compte, pour le calcul du surplus ci-dessus. # capacité avant la pause de midi compte, pour le calcul du surplus ci-dessus.
if cap > 0 and matin_nominal > 0:
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut}) slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
if not aprem_done: if not aprem_done:
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else max(pause_dejeuner_fin_min, jour["retour_min"] or 0)
cap = max(0, 23 * 60 + 59 - debut) cap = max(0, 23 * 60 + 59 - debut)
cap_pref = min(cap, max(0, depart_vise_min - debut)) if depart_vise_min else cap cap_pref = min(cap, max(0, depart_vise_min - debut)) if depart_vise_min else cap
# floor_room = marge disponible pour avancer le départ en cas d'avance, # floor_room = marge disponible pour avancer le départ en cas d'avance,

View File

@ -8,12 +8,15 @@ from odf.table import Table, TableColumn, TableRow, TableCell
from odf.text import P from odf.text import P
from calcul import compute_week, minutes_to_hhmm from calcul import compute_week, minutes_to_hhmm
from models import load_week_pointages, load_conges, list_all_weeks, load_plages, heures_jour_min from models import (
load_week_pointages, load_conges, load_astreintes, list_all_weeks,
load_plages, heures_jour_min,
)
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"] JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
HEADERS = [ HEADERS = [
"Date", "Jour", "Matin entrée", "Matin sortie", "Aprem entrée", "Aprem sortie", "Date", "Jour", "Matin entrée", "Matin sortie", "Aprem entrée", "Aprem sortie",
"Travaillé", "Dû", "Δ jour", "Congé", "Travaillé", "Dû", "Δ jour", "Congé", "Astreinte", "Retour",
] ]
@ -57,6 +60,12 @@ def _congé_label(cg: set) -> str:
return " + ".join(parts) return " + ".join(parts)
def _astreinte_label(jour: dict) -> str:
if jour.get("astreinte_fin"):
return f"fin {jour['astreinte_fin']}"
return "Astreinte" if jour.get("astreinte") else ""
def _jour_row(jour: dict, nom: str) -> TableRow: def _jour_row(jour: dict, nom: str) -> TableRow:
row = TableRow() row = TableRow()
for v in ( for v in (
@ -66,6 +75,8 @@ def _jour_row(jour: dict, nom: str) -> TableRow:
jour["travaille"], jour["du"], jour["travaille"], jour["du"],
("+" if jour["delta_min"] > 0 else "") + jour["delta"], ("+" if jour["delta_min"] > 0 else "") + jour["delta"],
_congé_label(jour["conge"]), _congé_label(jour["conge"]),
_astreinte_label(jour),
jour.get("retour"),
): ):
row.addElement(_cell(v)) row.addElement(_cell(v))
return row return row
@ -76,7 +87,7 @@ def _totals_row(bold: Style, travaille_min: int, du_min: int) -> TableRow:
for v in ("Total", "", "", "", "", "", for v in ("Total", "", "", "", "", "",
minutes_to_hhmm(travaille_min), minutes_to_hhmm(du_min), minutes_to_hhmm(travaille_min), minutes_to_hhmm(du_min),
("+" if travaille_min - du_min > 0 else "") + minutes_to_hhmm(travaille_min - du_min), ("+" if travaille_min - du_min > 0 else "") + minutes_to_hhmm(travaille_min - du_min),
""): "", "", ""):
row.addElement(_cell(v, bold)) row.addElement(_cell(v, bold))
return row return row
@ -92,6 +103,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes:
h_jour = heures_jour_min() h_jour = heures_jour_min()
plages = load_plages(user_id) plages = load_plages(user_id)
conges = load_conges(user_id) conges = load_conges(user_id)
astreintes = load_astreintes(user_id)
dates = _week_dates(year, week) dates = _week_dates(year, week)
raw = load_week_pointages(year, week, user_id) raw = load_week_pointages(year, week, user_id)
@ -101,7 +113,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes:
"aprem_entree": None, "aprem_sortie": None}) "aprem_entree": None, "aprem_sortie": None})
for d in dates for d in dates
] ]
result = compute_week(pointages, conges, h_jour, plages) result = compute_week(pointages, conges, h_jour, plages, astreintes)
table = Table(name=f"Semaine {week:02d}-{year}") table = Table(name=f"Semaine {week:02d}-{year}")
_size_columns(table, wide_col) _size_columns(table, wide_col)
@ -123,6 +135,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes:
h_jour = heures_jour_min() h_jour = heures_jour_min()
plages = load_plages(user_id) plages = load_plages(user_id)
conges = load_conges(user_id) conges = load_conges(user_id)
astreintes = load_astreintes(user_id)
weeks = sorted(wk for yr, wk in list_all_weeks(user_id) if yr == year) weeks = sorted(wk for yr, wk in list_all_weeks(user_id) if yr == year)
@ -136,7 +149,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes:
"aprem_entree": None, "aprem_sortie": None}) "aprem_entree": None, "aprem_sortie": None})
for d in dates for d in dates
] ]
result = compute_week(pointages, conges, h_jour, plages) result = compute_week(pointages, conges, h_jour, plages, astreintes)
travaille_min = sum(j["travaille_min"] for j in result["jours"]) travaille_min = sum(j["travaille_min"] for j in result["jours"])
du_min = sum(j["du_min"] for j in result["jours"]) du_min = sum(j["du_min"] for j in result["jours"])
par_semaine.append((week, result, travaille_min, du_min)) par_semaine.append((week, result, travaille_min, du_min))

View File

@ -21,14 +21,18 @@ from fastapi.staticfiles import StaticFiles
from mail import send_mail from mail import send_mail
from models import ( from models import (
load_config, load_week_pointages, save_week_pointages, load_conges, load_config, load_week_pointages, save_week_pointages, load_conges,
toggle_conge, list_all_weeks, toggle_conge, list_all_weeks, load_astreintes, toggle_astreinte,
load_notif_config, save_notif_config, find_user_by_token, save_presence, load_notif_config, save_notif_config, find_user_by_token, save_presence,
regenerate_presence_token, regenerate_presence_token,
load_auth, save_auth, find_user_by_reset_token, load_auth, save_auth, find_user_by_reset_token,
load_plages, save_plages, heures_jour_min, load_plages, save_plages, heures_jour_min,
append_log, load_logs, load_or_create_secret, append_log, load_logs, load_or_create_secret,
) )
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues 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,
)
from stats import compute_all_stats from stats import compute_all_stats
from export import export_semaine_ods, export_annee_ods from export import export_semaine_ods, export_annee_ods
from notifications import reminder_loop, send_ntfy, is_safe_ntfy_server from notifications import reminder_loop, send_ntfy, is_safe_ntfy_server
@ -303,21 +307,29 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id):
"aprem_entree": None, "aprem_sortie": None}) "aprem_entree": None, "aprem_sortie": None})
for d in dates for d in dates
] ]
result = compute_week(pointages, conges, h_jour, load_plages(user_id)) result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id))
result["heures_jour"] = minutes_to_hhmm(h_jour) result["heures_jour"] = minutes_to_hhmm(h_jour)
return {"result": result} return {"result": result}
def _build_jour_ctx(date, conges, h_jour, user_id): def _build_jour_ctx(date, conges, h_jour, user_id):
from calcul import heures_travaillees
d = datetime.strptime(date, "%Y-%m-%d").date() d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar() iso = d.isocalendar()
raw = load_week_pointages(iso.year, iso.week, user_id) raw = load_week_pointages(iso.year, iso.week, user_id)
plages = load_plages(user_id)
astreintes = set(load_astreintes(user_id))
p_by_date = {p["date"]: p for p in raw} p_by_date = {p["date"]: p for p in raw}
p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None, p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None}) "aprem_entree": None, "aprem_sortie": None})
trav = heures_travaillees(p) conges_date = {c["type"] for c in conges if c["date"] == date}
du = heures_dues(date, conges, h_jour) # Retour au bureau après 11h de repos, calculé depuis la fin de la dernière
# activité d'astreinte (saisie sur le jour ou la veille qualifiés)
retour_min = retour_apres_repos(date, p_by_date, plages, astreintes)
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages)
trav = heures_travaillees(p, retour_min)
du = heures_dues(date, conges, h_jour, plages, retour_min)
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 delta = trav - du
return { return {
**p, **p,
@ -325,7 +337,14 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
"travaille": minutes_to_hhmm(trav), "travaille": minutes_to_hhmm(trav),
"du": minutes_to_hhmm(du), "du": minutes_to_hhmm(du),
"delta": minutes_to_hhmm(delta), "delta": minutes_to_hhmm(delta),
"conge": {c["type"] for c in conges if c["date"] == date}, "conge": conges_date,
"du_full_min": du_full,
"matin_excl_min": matin_ex, "aprem_excl_min": aprem_ex,
"matin_nominal_min": matin_nom, "aprem_nominal_min": aprem_nom,
"astreinte": date in astreintes,
"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,
} }
@ -372,13 +391,18 @@ def _oob_time_cells(date, jour, plages):
cg = jour["conge"] cg = jour["conge"]
matin_cg = "matin" in cg or "jour" in cg matin_cg = "matin" in cg or "jour" in cg
aprem_cg = "aprem" in cg or "jour" in cg aprem_cg = "aprem" in cg or "jour" in cg
matin_warn = not matin_cg and jour["du_min"] > 0 and ( retour_min = jour.get("retour_min") or 0
(jour.get("matin_entree") and jour["matin_entree"] > plages["matin_debut"]) or # Une demi-journée entièrement couverte par le repos de 11h n'est plus exigée
(jour.get("matin_sortie") and jour["matin_sortie"] < plages["matin_fin"]) matin_requis = not matin_cg and jour["du_min"] > 0 and jour.get("matin_nominal_min", 0) > 0
aprem_requis = not aprem_cg and jour["du_min"] > 0 and jour.get("aprem_nominal_min", 0) > 0
# L'heure de retour décale d'autant la limite d'arrivée admise sans avertissement
matin_warn = matin_requis and (
(jour.get("matin_entree") and hhmm_to_minutes(jour["matin_entree"]) > max(hhmm_to_minutes(plages["matin_debut"]), retour_min)) or
(jour.get("matin_sortie") and hhmm_to_minutes(jour["matin_sortie"]) < hhmm_to_minutes(plages["matin_fin"]))
) )
aprem_warn = not aprem_cg and jour["du_min"] > 0 and ( aprem_warn = aprem_requis and (
(jour.get("aprem_entree") and jour["aprem_entree"] > plages["aprem_debut"]) or (jour.get("aprem_entree") and hhmm_to_minutes(jour["aprem_entree"]) > max(hhmm_to_minutes(plages["aprem_debut"]), retour_min)) or
(jour.get("aprem_sortie") and jour["aprem_sortie"] < plages["aprem_fin"]) (jour.get("aprem_sortie") and hhmm_to_minutes(jour["aprem_sortie"]) < hhmm_to_minutes(plages["aprem_fin"]))
) )
matin_cls = "td-fill cg" if matin_cg else ("td-fill warn" if matin_warn else "td-fill") matin_cls = "td-fill cg" if matin_cg else ("td-fill warn" if matin_warn else "td-fill")
aprem_cls = "td-fill cg" if aprem_cg else ("td-fill warn" if aprem_warn else "td-fill") aprem_cls = "td-fill cg" if aprem_cg else ("td-fill warn" if aprem_warn else "td-fill")
@ -401,22 +425,49 @@ def _oob_time_cells(date, jour, plages):
return matin_html + aprem_html return matin_html + aprem_html
def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id): def _btns_html(date, jour):
iso = d.isocalendar() """Boutons congé + astreinte d'une ligne (cible du swap HTMX)."""
plages = load_plages(user_id)
jour = _build_jour_ctx(date, conges, h_jour, user_id)
cg = jour["conge"] cg = jour["conge"]
btn_ma_on = " on" if "matin" in cg else "" btn_ma_on = " on" if "matin" in cg else ""
btn_am_on = " on" if "aprem" in cg else "" btn_am_on = " on" if "aprem" in cg else ""
btn_j_on = " jour-on" if "jour" in cg else "" btn_j_on = " jour-on" if "jour" in cg else ""
btns_html = ( btn_as_on = " as-on" if jour.get("astreinte") else ""
return (
f'<div id="cg-btn-{_e(date)}" hx-swap-oob="true">' f'<div id="cg-btn-{_e(date)}" hx-swap-oob="true">'
f'<div class="conge-btns">' f'<div class="conge-btns">'
f'<button hx-post="/conge/{_e(date)}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>' f'<button hx-post="/conge/{_e(date)}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>'
f'<button hx-post="/conge/{_e(date)}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>' f'<button hx-post="/conge/{_e(date)}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>'
f'<button hx-post="/conge/{_e(date)}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>' f'<button hx-post="/conge/{_e(date)}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
f'<button hx-post="/astreinte/{_e(date)}/toggle" hx-swap="none" class="btn-cg btn-as{btn_as_on}" title="Jour d&#39;astreinte">AS</button>'
f'</div></div>' f'</div></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."""
if not jour.get("astreinte_qualifiee"):
return f'<div id="as-block-{_e(date)}" hx-swap-oob="true"></div>'
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>')
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'{hint}'
f'</div>'
)
def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
iso = d.isocalendar()
plages = load_plages(user_id)
jour = _build_jour_ctx(date, conges, h_jour, user_id)
btns_html = _btns_html(date, jour)
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id) soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id)
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse( return HTMLResponse(
@ -432,11 +483,19 @@ def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
iso = d.isocalendar() iso = d.isocalendar()
plages = load_plages(user_id) plages = load_plages(user_id)
jour = _build_jour_ctx(date, conges, h_jour, user_id) jour = _build_jour_ctx(date, conges, h_jour, user_id)
# Le retour au plus tôt du lendemain dépend des opérations saisies aujourd'hui
as_blocks = _as_block_html(date, jour)
lendemain = d + timedelta(days=1)
lend_iso = lendemain.isocalendar()
if (lend_iso.year, lend_iso.week) == (iso.year, iso.week):
lendemain_str = lendemain.strftime("%Y-%m-%d")
as_blocks += _as_block_html(lendemain_str, _build_jour_ctx(lendemain_str, conges, h_jour, user_id))
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id) soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id)
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse( return HTMLResponse(
_oob_calc(date, jour) _oob_calc(date, jour)
+ _oob_time_cells(date, jour, plages) + _oob_time_cells(date, jour, plages)
+ as_blocks
+ _oob_week_extras(soldes_ctx["result"]) + _oob_week_extras(soldes_ctx["result"])
+ soldes_html + soldes_html
) )
@ -633,7 +692,7 @@ def semaine(request: Request, year: int, week: int):
"aprem_entree": None, "aprem_sortie": None}) "aprem_entree": None, "aprem_sortie": None})
for d in dates for d in dates
] ]
result = compute_week(pointages, conges, h_jour, plages) result = compute_week(pointages, conges, h_jour, plages, load_astreintes(user_id))
prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1) 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) next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
@ -659,6 +718,7 @@ def save_pointage(
request: Request, date: str, request: Request, date: str,
matin_entree: str = Form(""), matin_sortie: str = Form(""), matin_entree: str = Form(""), matin_sortie: str = Form(""),
aprem_entree: str = Form(""), aprem_sortie: str = Form(""), aprem_entree: str = Form(""), aprem_sortie: str = Form(""),
astreinte_fin: str = Form(""),
): ):
user_id = _get_user(request) user_id = _get_user(request)
if not user_id: if not user_id:
@ -674,10 +734,18 @@ def save_pointage(
iso = d.isocalendar() iso = d.isocalendar()
raw = load_week_pointages(iso.year, iso.week, user_id) raw = load_week_pointages(iso.year, iso.week, user_id)
p_by_date = {p["date"]: p for p in raw} 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.
if jour_astreinte_qualifie(date, set(load_astreintes(user_id))):
champs_astreinte = {"astreinte_fin": clean(astreinte_fin)}
else:
champs_astreinte = {"astreinte_fin": p_by_date.get(date, {}).get("astreinte_fin")}
p_by_date[date] = { p_by_date[date] = {
"date": date, "date": date,
"matin_entree": clean(matin_entree), "matin_sortie": clean(matin_sortie), "matin_entree": clean(matin_entree), "matin_sortie": clean(matin_sortie),
"aprem_entree": clean(aprem_entree), "aprem_sortie": clean(aprem_sortie), "aprem_entree": clean(aprem_entree), "aprem_sortie": clean(aprem_sortie),
**champs_astreinte,
} }
save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]), user_id) save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]), user_id)
conges = load_conges(user_id) conges = load_conges(user_id)
@ -710,6 +778,40 @@ def toggle_conge_route(date: str, type_conge: str, request: Request):
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303) return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
@app.post("/astreinte/{date}/toggle", response_class=HTMLResponse)
def toggle_astreinte_route(date: str, request: Request):
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
toggle_astreinte(date, user_id)
d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar()
if not request.headers.get("HX-Request"):
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
conges = load_conges(user_id)
h_jour = heures_jour_min()
plages = load_plages(user_id)
# Marquer une astreinte qualifie ce jour ET son lendemain (la nuit qui suit) :
# les deux lignes doivent rafraîchir saisie d'opération, avertissements et cibles.
dates_a_rafraichir = [date]
lendemain = d + timedelta(days=1)
lend_iso = lendemain.isocalendar()
if (lend_iso.year, lend_iso.week) == (iso.year, iso.week):
dates_a_rafraichir.append(lendemain.strftime("%Y-%m-%d"))
html = ""
for date_str in dates_a_rafraichir:
jour = _build_jour_ctx(date_str, conges, h_jour, user_id)
html += _oob_time_cells(date_str, jour, plages)
html += _as_block_html(date_str, jour)
html += _oob_calc(date_str, jour)
html += _btns_html(date, _build_jour_ctx(date, conges, h_jour, user_id))
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id)
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse(html + _oob_week_extras(soldes_ctx["result"]) + soldes_html)
@app.get("/stats", response_class=HTMLResponse) @app.get("/stats", response_class=HTMLResponse)
def stats_page(request: Request): def stats_page(request: Request):
user_id = _get_user(request) user_id = _get_user(request)

View File

@ -146,6 +146,40 @@ def save_conges(conges: list[dict], user_id: str = ""):
) )
# ── Astreintes ────────────────────────────────────────────────────────────────
def _astreintes_file(user_id: str) -> Path:
return _user_root(user_id) / "astreintes.json"
def load_astreintes(user_id: str = "") -> list[str]:
"""Dates marquées comme jours d'astreinte (triées, dédupliquées)."""
f = _astreintes_file(user_id)
if not f.exists():
return []
try:
dates = json.loads(f.read_text())
except json.JSONDecodeError:
return []
return sorted(set(d for d in dates if isinstance(d, str)))
def save_astreintes(dates: list[str], user_id: str = ""):
_ensure_user_dirs(user_id)
_astreintes_file(user_id).write_text(
json.dumps(sorted(set(dates)), indent=2, ensure_ascii=False)
)
def toggle_astreinte(date: str, user_id: str = ""):
dates = load_astreintes(user_id)
if date in dates:
dates.remove(date)
else:
dates.append(date)
save_astreintes(dates, user_id)
# ── Auth (email + password) ──────────────────────────────────────────────────── # ── Auth (email + password) ────────────────────────────────────────────────────
def _auth_file(user_id: str) -> Path: def _auth_file(user_id: str) -> Path:

View File

@ -3,9 +3,13 @@ import statistics as _st
from datetime import datetime, timedelta, date as _date from datetime import datetime, timedelta, date as _date
from models import ( from models import (
load_week_pointages, load_conges, list_all_weeks, load_plages, heures_jour_min, load_week_pointages, load_conges, load_astreintes, list_all_weeks,
load_plages, heures_jour_min,
)
from calcul import (
hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues,
retour_apres_repos,
) )
from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues
MOIS_FR = [ MOIS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin", "janvier", "février", "mars", "avril", "mai", "juin",
@ -60,6 +64,7 @@ def _compliance_over(all_days: list[tuple], cg_set: set, plages: dict) -> dict:
def compute_all_stats(user_id: str = "") -> dict: def compute_all_stats(user_id: str = "") -> dict:
all_weeks = sorted(list_all_weeks(user_id)) all_weeks = sorted(list_all_weeks(user_id))
conges = load_conges(user_id) conges = load_conges(user_id)
astreintes = set(load_astreintes(user_id))
h_jour = heures_jour_min() h_jour = heures_jour_min()
plages = load_plages(user_id) plages = load_plages(user_id)
cg_set = {(c["date"], c["type"]) for c in conges} cg_set = {(c["date"], c["type"]) for c in conges}
@ -107,6 +112,9 @@ def compute_all_stats(user_id: str = "") -> dict:
matin_filled = mc or (bool(p.get("matin_entree")) and bool(p.get("matin_sortie"))) matin_filled = mc or (bool(p.get("matin_entree")) and bool(p.get("matin_sortie")))
aprem_filled = ac or (bool(p.get("aprem_entree")) and bool(p.get("aprem_sortie"))) aprem_filled = ac or (bool(p.get("aprem_entree")) and bool(p.get("aprem_sortie")))
# Retour au bureau après 11h de repos : calculé depuis la fin de la
# dernière activité d'astreinte (saisie sur le jour ou la veille qualifiés)
retour_min = retour_apres_repos(date, p_by_date, plages, astreintes)
all_days.append((date, p)) all_days.append((date, p))
d = datetime.strptime(date, "%Y-%m-%d").date() d = datetime.strptime(date, "%Y-%m-%d").date()
@ -119,8 +127,8 @@ def compute_all_stats(user_id: str = "") -> dict:
week_has_incomplete = True week_has_incomplete = True
continue continue
trav = heures_travaillees(p) trav = heures_travaillees(p, retour_min)
du = heures_dues(date, conges, h_jour) du = heures_dues(date, conges, h_jour, plages, retour_min)
week_delta += trav - du week_delta += trav - du
if not mc: if not mc:
@ -129,6 +137,9 @@ def compute_all_stats(user_id: str = "") -> dict:
v = hhmm_to_minutes(p[slot]) v = hhmm_to_minutes(p[slot])
slot_pairs[slot].append((v, date)) slot_pairs[slot].append((v, date))
op, thr = TARGETS[slot] op, thr = TARGETS[slot]
# Une arrivée décalée par le repos de 11h n'est pas un retard
if slot == "matin_entree" and retour_min:
thr = max(thr, retour_min)
comp_n[slot] += 1 comp_n[slot] += 1
comp_ok[slot] += 1 if (v <= thr if op == "le" else v >= thr) else 0 comp_ok[slot] += 1 if (v <= thr if op == "le" else v >= thr) else 0
if not ac: if not ac:
@ -137,6 +148,8 @@ def compute_all_stats(user_id: str = "") -> dict:
v = hhmm_to_minutes(p[slot]) v = hhmm_to_minutes(p[slot])
slot_pairs[slot].append((v, date)) slot_pairs[slot].append((v, date))
op, thr = TARGETS[slot] op, thr = TARGETS[slot]
if slot == "aprem_entree" and retour_min:
thr = max(thr, retour_min)
comp_n[slot] += 1 comp_n[slot] += 1
comp_ok[slot] += 1 if (v <= thr if op == "le" else v >= thr) else 0 comp_ok[slot] += 1 if (v <= thr if op == "le" else v >= thr) else 0

View File

@ -1,11 +1,15 @@
{% set matin_cg = 'matin' in j.conge or 'jour' in j.conge %} {% set matin_cg = 'matin' in j.conge or 'jour' in j.conge %}
{% set aprem_cg = 'aprem' in j.conge or 'jour' in j.conge %} {% set aprem_cg = 'aprem' in j.conge or 'jour' in j.conge %}
{% set matin_warn = not matin_cg and j.du_min > 0 and ( {% set matin_requis = not matin_cg and j.du_min > 0 and j.matin_nominal_min > 0 %}
(j.matin_entree and j.matin_entree > plages.matin_debut) or {% set aprem_requis = not aprem_cg and j.du_min > 0 and j.aprem_nominal_min > 0 %}
{% set borne_matin = [plages.matin_debut, j.retour or plages.matin_debut] | max %}
{% set borne_aprem = [plages.aprem_debut, j.retour or plages.aprem_debut] | max %}
{% set matin_warn = matin_requis and (
(j.matin_entree and j.matin_entree > borne_matin) or
(j.matin_sortie and j.matin_sortie < plages.matin_fin) (j.matin_sortie and j.matin_sortie < plages.matin_fin)
) %} ) %}
{% set aprem_warn = not aprem_cg and j.du_min > 0 and ( {% set aprem_warn = aprem_requis and (
(j.aprem_entree and j.aprem_entree > plages.aprem_debut) or (j.aprem_entree and j.aprem_entree > borne_aprem) or
(j.aprem_sortie and j.aprem_sortie < plages.aprem_fin) (j.aprem_sortie and j.aprem_sortie < plages.aprem_fin)
) %} ) %}
<tr id="row-{{ j.date }}"> <tr id="row-{{ j.date }}">
@ -37,6 +41,17 @@
data-date="{{ j.date }}" value="{{ j.aprem_sortie or '' }}"> data-date="{{ j.date }}" value="{{ j.aprem_sortie or '' }}">
</div> </div>
</div> </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">
{% 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 %}
</div>
{% else %}
<div id="as-block-{{ j.date }}"></div>
{% endif %}
</td> </td>
<td class="col-calc" data-label="Travaillé"> <td class="col-calc" data-label="Travaillé">
@ -102,6 +117,10 @@
hx-swap="none" hx-swap="none"
class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}" class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}"
title="Journée entière">J</button> title="Journée entière">J</button>
<button hx-post="/astreinte/{{ j.date }}/toggle"
hx-swap="none"
class="btn-cg btn-as {% if j.astreinte %}as-on{% endif %}"
title="Jour d'astreinte">AS</button>
</div> </div>
</div> </div>
</td> </td>

View File

@ -428,6 +428,33 @@
.btn-cg.on { background: var(--conge-bg); border-color: var(--conge-bd); color: var(--conge-txt); } .btn-cg.on { background: var(--conge-bg); border-color: var(--conge-bd); color: var(--conge-txt); }
.btn-cg.btn-jour { border-radius: 50%; width: 22px; height: 22px; font-size: 9px; } .btn-cg.btn-jour { border-radius: 50%; width: 22px; height: 22px; font-size: 9px; }
.btn-cg.jour-on { background: var(--accent-2); border-color: #955505; color: #fff; } .btn-cg.jour-on { background: var(--accent-2); border-color: #955505; color: #fff; }
.btn-cg.btn-as { border-radius: 4px; }
.btn-cg.as-on { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Astreinte : opération hors horaires + heure de retour après 11h de repos */
.as-block {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
padding: .3rem .65rem .45rem;
border-top: 1px dashed var(--rule);
}
.as-label {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--accent-2);
white-space: nowrap;
}
.as-block .time-input { width: 68px; }
.as-hint {
font-family: var(--mono);
font-size: 10px;
color: var(--accent);
white-space: nowrap;
}
/* ── Tableau hebdo en cartes empilées sous 700px (évite le scroll horizontal) ── */ /* ── Tableau hebdo en cartes empilées sous 700px (évite le scroll horizontal) ── */
@media (max-width: 700px) { @media (max-width: 700px) {
@ -490,6 +517,8 @@
.pointage-table .btn-valider { width: 40px; height: 40px; font-size: 16px; } .pointage-table .btn-valider { width: 40px; height: 40px; font-size: 16px; }
.pointage-table .btn-cg { width: 34px; height: 34px; font-size: 11px; } .pointage-table .btn-cg { width: 34px; height: 34px; font-size: 11px; }
.pointage-table .btn-cg.btn-jour { width: 32px; height: 32px; } .pointage-table .btn-cg.btn-jour { width: 32px; height: 32px; }
.as-block { padding: .4rem .75rem .55rem; }
.as-block .time-input { width: 92px; }
} }
/* ── Historique ── */ /* ── Historique ── */

235
app/tests/test_astreinte.py Normal file
View File

@ -0,0 +1,235 @@
"""Astreinte : fin de dernière activité, repos de 11h et heure de retour décalée."""
from calcul import (
compute_week, heures_dues, heures_travaillees, hhmm_to_minutes,
jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour,
retour_apres_repos, DEFAULT_PLAGES,
)
import models
HEURES_JOUR_MIN = 468 # 7h48
PLAGES = dict(DEFAULT_PLAGES)
def _pointage(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None,
astreinte_fin=None):
return {
"date": date,
"matin_entree": matin_entree, "matin_sortie": matin_sortie,
"aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie,
"astreinte_fin": astreinte_fin,
}
# ── Qualification ─────────────────────────────────────────────────────────────
def test_jour_astreinte_qualifie():
astreintes = ["2026-07-14"]
assert jour_astreinte_qualifie("2026-07-14", astreintes) # jour d'astreinte
assert jour_astreinte_qualifie("2026-07-15", astreintes) # lendemain
assert not jour_astreinte_qualifie("2026-07-13", astreintes)
assert not jour_astreinte_qualifie("2026-07-15", [])
# ── Heures dues : exclusion avant l'heure de retour ───────────────────────────
def test_heures_dues_retour_avant_ouverture_sans_effet():
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("08:30")) == HEURES_JOUR_MIN
def test_heures_dues_retour_midi_retire_la_presence_du_matin():
# 13:00 → plage matin 09:00-12:00 (180 min) retirée du décompte
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("13:00")) == HEURES_JOUR_MIN - 180
def test_heures_dues_retour_apres_midi_retire_matin_et_partie_aprem():
# 15:00 → matin (180) + 14:00→15:00 (60)
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("15:00")) == HEURES_JOUR_MIN - 240
def test_heures_dues_conge_matin_pas_de_double_exclusion():
conges = [{"date": "2026-07-13", "type": "matin"}]
# base = demi-journée aprem (234) ; le matin déjà en congé n'est pas décompté deux fois
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("13:00")) == 234
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("15:00")) == 234 - 60
def test_exclusion_retour_sans_retour():
assert exclusion_retour(set(), None, PLAGES) == (0, 0)
# ── Heures travaillées : la présence avant le retour ne compte pas ────────────
def test_heures_travaillees_presence_avant_retour_non_comptee():
p = _pointage("2026-07-13", "09:00", "12:00", "14:00", "18:00")
# matin 09:00→12:00 entièrement avant le retour 13:00 → 0 ; aprem 14:00→18:00 → 240
assert heures_travaillees(p, hhmm_to_minutes("13:00")) == 240
assert heures_travaillees(p) == 180 + 240
def test_heures_travaillees_entree_chevauchant_le_retour():
p = _pointage("2026-07-13", "08:00", "12:00")
# entrée décalée à 10:30 → 90 min comptées
assert heures_travaillees(p, hhmm_to_minutes("10:30")) == 90
# ── Retour au plus tôt après 11h de repos ─────────────────────────────────────
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")}
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_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")}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 30
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
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"),
}
assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60
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")}
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
# ── Nominaux ──────────────────────────────────────────────────────────────────
def test_nominaux_exclusion_totale_du_matin():
m, a = nominaux_du_jour(set(), 360, 180, 0)
assert (m, a) == (0, 180)
def test_nominaux_sans_exclusion_reste_la_moitié():
m, a = nominaux_du_jour(set(), 468, 0, 0)
assert (m, a) == (234, 234)
# ── compute_week ──────────────────────────────────────────────────────────────
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)."""
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-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["cible_matin"] is None
assert mercredi["cible_aprem"] == "18:18"
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)."""
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-15"),
_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["retour"] == "17:00"
# matin (180) + 14:00→17:00 (180) exclus → dû = 108
assert mercredi["du_min"] == 468 - 360
assert mercredi["cible_matin"] is None
# 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"
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."""
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-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
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."""
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-15"),
_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["retour"] == "10:30"
assert mercredi["cible_matin_entree"] == "10:30"
assert mercredi["cible_matin"] == "12:00"
# aprem : nominal 234 + 54 reportées = 288 depuis 13:30 → 18:18
assert mercredi["cible_aprem"] == "18:18"
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."""
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-16", "09:00", "12:00", "13:30", "16:30"),
_pointage("2026-07-17", "09:00", "12:00", "13:30", "16:30"),
]
result = compute_week(pointages, [], heures_jour, PLAGES, ["2026-07-14"])
mercredi = result["jours"][2]
assert mercredi["matin_nominal_min"] == 0
assert mercredi["is_complete"] is True
assert mercredi["cumul_visible"] is True
assert mercredi["du_min"] == 180 # 6h dues - 3h de présence matin retirées
# ── Stockage des astreintes ───────────────────────────────────────────────────
def test_toggle_astreinte_persiste():
models.toggle_astreinte("2026-07-14", "u-astreinte")
assert models.load_astreintes("u-astreinte") == ["2026-07-14"]
models.toggle_astreinte("2026-07-14", "u-astreinte")
assert models.load_astreintes("u-astreinte") == []