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:
31
README.md
31
README.md
@ -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
|
||||
|
||||
`FastAPI` · `HTMX` · `Jinja2` · fichiers JSON · `Docker`
|
||||
|
||||
179
app/calcul.py
179
app/calcul.py
@ -1,5 +1,5 @@
|
||||
"""Business logic: compute worked hours and expected hours per day/week."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DEFAULT_PLAGES = {
|
||||
"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)
|
||||
}
|
||||
|
||||
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:
|
||||
if not val:
|
||||
@ -25,32 +27,132 @@ def minutes_to_hhmm(minutes: int) -> str:
|
||||
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.
|
||||
|
||||
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")
|
||||
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
|
||||
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:
|
||||
matin = max(0, now_min - hhmm_to_minutes(p["matin_entree"]))
|
||||
matin = max(0, now_min - entree_min(p["matin_entree"]))
|
||||
|
||||
aprem = 0
|
||||
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:
|
||||
aprem = max(0, now_min - hhmm_to_minutes(p["aprem_entree"]))
|
||||
aprem = max(0, now_min - entree_min(p["aprem_entree"]))
|
||||
|
||||
return matin + aprem
|
||||
|
||||
|
||||
def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int) -> int:
|
||||
"""Return expected minutes for a date, accounting for vacations."""
|
||||
def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int,
|
||||
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()
|
||||
# Weekend = no hours due
|
||||
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
|
||||
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)."""
|
||||
plages = {**DEFAULT_PLAGES, **(plages or {})}
|
||||
astreintes = set(astreintes or [])
|
||||
matin_debut_min = hhmm_to_minutes(plages["matin_debut"])
|
||||
matin_fin_min = hhmm_to_minutes(plages["matin_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_du = 0
|
||||
jours = []
|
||||
p_by_date = {p["date"]: p for p in pointages}
|
||||
|
||||
for p in pointages:
|
||||
travaille = heures_travaillees(p)
|
||||
du = heures_dues(p["date"], conges, heures_jour_min)
|
||||
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)
|
||||
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
|
||||
total_travaille += travaille
|
||||
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),
|
||||
"du": minutes_to_hhmm(du),
|
||||
"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:
|
||||
cg = jour["conge"]
|
||||
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
|
||||
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 or jour["aprem_nominal_min"] == 0
|
||||
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
|
||||
@ -146,10 +274,11 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
for jour in jours:
|
||||
if jour["date"] != today:
|
||||
continue
|
||||
retour = jour.get("retour_min") or 0
|
||||
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"):
|
||||
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
|
||||
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"]
|
||||
if du == 0:
|
||||
continue
|
||||
matin_nominal = 0 if "matin" in cg else (du if "aprem" in cg else du // 2)
|
||||
aprem_nominal = 0 if "aprem" in cg else (du if "matin" in cg else du - du // 2)
|
||||
matin_nominal = jour["matin_nominal_min"]
|
||||
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
|
||||
aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg
|
||||
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
|
||||
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
|
||||
@ -175,9 +305,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
aprem_nominal += surplus
|
||||
# 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.
|
||||
if cap > 0 and matin_nominal > 0:
|
||||
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
|
||||
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_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,
|
||||
|
||||
@ -8,12 +8,15 @@ from odf.table import Table, TableColumn, TableRow, TableCell
|
||||
from odf.text import P
|
||||
|
||||
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"]
|
||||
HEADERS = [
|
||||
"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)
|
||||
|
||||
|
||||
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:
|
||||
row = TableRow()
|
||||
for v in (
|
||||
@ -66,6 +75,8 @@ def _jour_row(jour: dict, nom: str) -> TableRow:
|
||||
jour["travaille"], jour["du"],
|
||||
("+" if jour["delta_min"] > 0 else "") + jour["delta"],
|
||||
_congé_label(jour["conge"]),
|
||||
_astreinte_label(jour),
|
||||
jour.get("retour"),
|
||||
):
|
||||
row.addElement(_cell(v))
|
||||
return row
|
||||
@ -76,7 +87,7 @@ def _totals_row(bold: Style, travaille_min: int, du_min: int) -> 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),
|
||||
""):
|
||||
"", "", ""):
|
||||
row.addElement(_cell(v, bold))
|
||||
return row
|
||||
|
||||
@ -92,6 +103,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes:
|
||||
h_jour = heures_jour_min()
|
||||
plages = load_plages(user_id)
|
||||
conges = load_conges(user_id)
|
||||
astreintes = load_astreintes(user_id)
|
||||
|
||||
dates = _week_dates(year, week)
|
||||
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})
|
||||
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}")
|
||||
_size_columns(table, wide_col)
|
||||
@ -123,6 +135,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes:
|
||||
h_jour = heures_jour_min()
|
||||
plages = load_plages(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)
|
||||
|
||||
@ -136,7 +149,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes:
|
||||
"aprem_entree": None, "aprem_sortie": None})
|
||||
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"])
|
||||
du_min = sum(j["du_min"] for j in result["jours"])
|
||||
par_semaine.append((week, result, travaille_min, du_min))
|
||||
|
||||
140
app/main.py
140
app/main.py
@ -21,14 +21,18 @@ from fastapi.staticfiles import StaticFiles
|
||||
from mail import send_mail
|
||||
from models import (
|
||||
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,
|
||||
regenerate_presence_token,
|
||||
load_auth, save_auth, find_user_by_reset_token,
|
||||
load_plages, save_plages, heures_jour_min,
|
||||
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 export import export_semaine_ods, export_annee_ods
|
||||
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})
|
||||
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)
|
||||
return {"result": result}
|
||||
|
||||
|
||||
def _build_jour_ctx(date, conges, h_jour, user_id):
|
||||
from calcul import heures_travaillees
|
||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
iso = d.isocalendar()
|
||||
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 = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None,
|
||||
"aprem_entree": None, "aprem_sortie": None})
|
||||
trav = heures_travaillees(p)
|
||||
du = heures_dues(date, conges, h_jour)
|
||||
conges_date = {c["type"] for c in conges if c["date"] == date}
|
||||
# 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
|
||||
return {
|
||||
**p,
|
||||
@ -325,7 +337,14 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
|
||||
"travaille": minutes_to_hhmm(trav),
|
||||
"du": minutes_to_hhmm(du),
|
||||
"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"]
|
||||
matin_cg = "matin" 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 (
|
||||
(jour.get("matin_entree") and jour["matin_entree"] > plages["matin_debut"]) or
|
||||
(jour.get("matin_sortie") and jour["matin_sortie"] < plages["matin_fin"])
|
||||
retour_min = jour.get("retour_min") or 0
|
||||
# Une demi-journée entièrement couverte par le repos de 11h n'est plus exigée
|
||||
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 (
|
||||
(jour.get("aprem_entree") and jour["aprem_entree"] > plages["aprem_debut"]) or
|
||||
(jour.get("aprem_sortie") and jour["aprem_sortie"] < plages["aprem_fin"])
|
||||
aprem_warn = aprem_requis and (
|
||||
(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 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")
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
def _btns_html(date, jour):
|
||||
"""Boutons congé + astreinte d'une ligne (cible du swap HTMX)."""
|
||||
cg = jour["conge"]
|
||||
btn_ma_on = " on" if "matin" in cg else ""
|
||||
btn_am_on = " on" if "aprem" 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 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)}/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="/astreinte/{_e(date)}/toggle" hx-swap="none" class="btn-cg btn-as{btn_as_on}" title="Jour d'astreinte">AS</button>'
|
||||
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_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
||||
return HTMLResponse(
|
||||
@ -432,11 +483,19 @@ def _htmx_calc_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)
|
||||
# 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_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
||||
return HTMLResponse(
|
||||
_oob_calc(date, jour)
|
||||
+ _oob_time_cells(date, jour, plages)
|
||||
+ as_blocks
|
||||
+ _oob_week_extras(soldes_ctx["result"])
|
||||
+ soldes_html
|
||||
)
|
||||
@ -633,7 +692,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)
|
||||
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)
|
||||
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,
|
||||
matin_entree: str = Form(""), matin_sortie: str = Form(""),
|
||||
aprem_entree: str = Form(""), aprem_sortie: str = Form(""),
|
||||
astreinte_fin: str = Form(""),
|
||||
):
|
||||
user_id = _get_user(request)
|
||||
if not user_id:
|
||||
@ -674,10 +734,18 @@ 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.
|
||||
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] = {
|
||||
"date": date,
|
||||
"matin_entree": clean(matin_entree), "matin_sortie": clean(matin_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)
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
def stats_page(request: Request):
|
||||
user_id = _get_user(request)
|
||||
|
||||
@ -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) ────────────────────────────────────────────────────
|
||||
|
||||
def _auth_file(user_id: str) -> Path:
|
||||
|
||||
21
app/stats.py
21
app/stats.py
@ -3,9 +3,13 @@ import statistics as _st
|
||||
from datetime import datetime, timedelta, date as _date
|
||||
|
||||
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 = [
|
||||
"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:
|
||||
all_weeks = sorted(list_all_weeks(user_id))
|
||||
conges = load_conges(user_id)
|
||||
astreintes = set(load_astreintes(user_id))
|
||||
h_jour = heures_jour_min()
|
||||
plages = load_plages(user_id)
|
||||
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")))
|
||||
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))
|
||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
@ -119,8 +127,8 @@ def compute_all_stats(user_id: str = "") -> dict:
|
||||
week_has_incomplete = True
|
||||
continue
|
||||
|
||||
trav = heures_travaillees(p)
|
||||
du = heures_dues(date, conges, h_jour)
|
||||
trav = heures_travaillees(p, retour_min)
|
||||
du = heures_dues(date, conges, h_jour, plages, retour_min)
|
||||
week_delta += trav - du
|
||||
|
||||
if not mc:
|
||||
@ -129,6 +137,9 @@ def compute_all_stats(user_id: str = "") -> dict:
|
||||
v = hhmm_to_minutes(p[slot])
|
||||
slot_pairs[slot].append((v, date))
|
||||
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_ok[slot] += 1 if (v <= thr if op == "le" else v >= thr) else 0
|
||||
if not ac:
|
||||
@ -137,6 +148,8 @@ def compute_all_stats(user_id: str = "") -> dict:
|
||||
v = hhmm_to_minutes(p[slot])
|
||||
slot_pairs[slot].append((v, date))
|
||||
op, thr = TARGETS[slot]
|
||||
if slot == "aprem_entree" and retour_min:
|
||||
thr = max(thr, retour_min)
|
||||
comp_n[slot] += 1
|
||||
comp_ok[slot] += 1 if (v <= thr if op == "le" else v >= thr) else 0
|
||||
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
{% 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 matin_warn = not matin_cg and j.du_min > 0 and (
|
||||
(j.matin_entree and j.matin_entree > plages.matin_debut) or
|
||||
{% set matin_requis = not matin_cg and j.du_min > 0 and j.matin_nominal_min > 0 %}
|
||||
{% 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)
|
||||
) %}
|
||||
{% set aprem_warn = not aprem_cg and j.du_min > 0 and (
|
||||
(j.aprem_entree and j.aprem_entree > plages.aprem_debut) or
|
||||
{% set aprem_warn = aprem_requis and (
|
||||
(j.aprem_entree and j.aprem_entree > borne_aprem) or
|
||||
(j.aprem_sortie and j.aprem_sortie < plages.aprem_fin)
|
||||
) %}
|
||||
<tr id="row-{{ j.date }}">
|
||||
@ -37,6 +41,17 @@
|
||||
data-date="{{ j.date }}" value="{{ j.aprem_sortie or '' }}">
|
||||
</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 class="col-calc" data-label="Travaillé">
|
||||
@ -102,6 +117,10 @@
|
||||
hx-swap="none"
|
||||
class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}"
|
||||
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>
|
||||
</td>
|
||||
|
||||
@ -428,6 +428,33 @@
|
||||
.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.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) ── */
|
||||
@media (max-width: 700px) {
|
||||
@ -490,6 +517,8 @@
|
||||
.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.btn-jour { width: 32px; height: 32px; }
|
||||
.as-block { padding: .4rem .75rem .55rem; }
|
||||
.as-block .time-input { width: 92px; }
|
||||
}
|
||||
|
||||
/* ── Historique ── */
|
||||
|
||||
235
app/tests/test_astreinte.py
Normal file
235
app/tests/test_astreinte.py
Normal 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") == []
|
||||
Reference in New Issue
Block a user