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:
181
app/calcul.py
181
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.
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user