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

@ -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.
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
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,