Astreinte : repos hebdomadaire de 35h en option (7 jours glissants)
Un week-end d'astreinte fragmenté peut ne contenir aucun trou de 35h
consécutives sans activité : le retour est alors repoussé à dernière
activité + 35h, et toute nouvelle intervention redémarre le décompte.
Désactivé par défaut, activable par compte dans Réglages ; la journée
couverte est créditée comme pour les 11h quotidiennes.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
137
app/calcul.py
137
app/calcul.py
@ -11,9 +11,11 @@ DEFAULT_PLAGES = {
|
||||
"depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun)
|
||||
"astreinte_forfait": "", # durée ajoutée au temps travaillé de chaque jour d'astreinte déclaré (HH:MM, vide = aucune)
|
||||
"astreinte_active": True, # préférence personnelle : ce compte gère-t-il les astreintes ?
|
||||
"repos_hebdo_active": False, # préférence personnelle : respecter le repos hebdomadaire de 35h (7 jours glissants)
|
||||
}
|
||||
|
||||
REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau
|
||||
REPOS_HEBDO_MIN = 35 * 60 # repos hebdomadaire : 24h + 11h consécutives, une fois par 7 jours glissants
|
||||
PAS_ASTREINTE_MIN = 15 # granularité du décompte astreinte : durée réelle arrondie au quart d'heure supérieur
|
||||
ASTREINTE_DEBUT_MIN = 18 * 60 # un jour d'astreinte déclaré couvre 18:00 → 18:00 le lendemain
|
||||
|
||||
@ -381,6 +383,118 @@ dernière activité d'astreinte.
|
||||
return best if best > hhmm_to_minutes(plages["matin_debut"]) else None
|
||||
|
||||
|
||||
# ── Repos hebdomadaire : 35h consécutives par 7 jours glissants ────────────────
|
||||
|
||||
def _activites_repos(p_by_date: dict, astreintes, jour0: date) -> list[tuple[int, int]]:
|
||||
"""Intervalles d'activité (minutes absolues depuis _EPOCH_DATE) autour du
|
||||
jour : créneaux pointés matin/après-midi, et interventions d'astreinte des
|
||||
jours qualifiés — la fin portée à la durée arrondie au quart d'heure
|
||||
supérieur, à cheval sur minuit le cas échéant. Une demi-journée entamée
|
||||
aujourd'hui (entrée sans sortie) court jusqu'à maintenant. Intervalles
|
||||
fusionnés et triés."""
|
||||
today = datetime.today()
|
||||
today_str = today.strftime("%Y-%m-%d")
|
||||
now_min = today.hour * 60 + today.minute
|
||||
intervals = []
|
||||
for i in range(-7, 2):
|
||||
d = jour0 + timedelta(days=i)
|
||||
row = p_by_date.get(d.isoformat())
|
||||
if not row:
|
||||
continue
|
||||
base = (d - _EPOCH_DATE).days * 1440
|
||||
for deb_k, fin_k in (("matin_entree", "matin_sortie"), ("aprem_entree", "aprem_sortie")):
|
||||
deb, fin = row.get(deb_k), row.get(fin_k)
|
||||
if deb and fin:
|
||||
intervals.append((base + hhmm_to_minutes(deb), base + hhmm_to_minutes(fin)))
|
||||
elif deb and d.isoformat() == today_str:
|
||||
intervals.append((base + hhmm_to_minutes(deb), base + now_min))
|
||||
if jour_astreinte_qualifie(d.isoformat(), astreintes):
|
||||
for ev in row.get("astreinte_events") or []:
|
||||
deb, fin = ev.get("debut"), ev.get("fin")
|
||||
if not (deb and fin):
|
||||
continue
|
||||
deb_m, fin_m = hhmm_to_minutes(deb), hhmm_to_minutes(fin)
|
||||
duree = fin_m - deb_m if fin_m >= deb_m else 24 * 60 - deb_m + fin_m
|
||||
fin_c = -(-duree // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
|
||||
intervals.append((base + deb_m, base + deb_m + fin_c))
|
||||
intervals.sort()
|
||||
fusionnes = []
|
||||
for deb, fin in intervals:
|
||||
if fin <= deb:
|
||||
continue
|
||||
if fusionnes and deb <= fusionnes[-1][1]:
|
||||
if fin > fusionnes[-1][1]:
|
||||
fusionnes[-1][1] = fin
|
||||
else:
|
||||
fusionnes.append([deb, fin])
|
||||
return [(deb, fin) for deb, fin in fusionnes]
|
||||
|
||||
|
||||
def retour_repos_hebdo(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None:
|
||||
"""Repos hebdomadaire : 35h consécutives (24h de repos hebdomadaire + 11h de
|
||||
repos quotidien) à obtenir une fois par période de 7 jours glissants. Une
|
||||
semaine normale le satisfait d'elle-même — le week-end offre le trou
|
||||
nécessaire ; un week-end d'astreinte fragmenté par plusieurs interventions
|
||||
peut ne plus contenir aucun trou de 35h : le retour au bureau est alors
|
||||
repoussé à dernière activité + 35h, et toute nouvelle perturbation avant
|
||||
cette échéance redémarre le décompte.
|
||||
|
||||
Sont comptées comme activité, sur la fenêtre [J-6 00:00, J+1 00:00[ : les
|
||||
créneaux pointés et les interventions d'astreinte des jours qualifiés (une
|
||||
demi-journée en cours aujourd'hui court jusqu'à maintenant). Un trou de 35h
|
||||
ou plus ENTRE deux activités (ou avant la première) valide le repos ; le
|
||||
trou final, après la dernière activité, ne compte pas : il ne deviendra
|
||||
repos que si l'heure de retour le respecte.
|
||||
|
||||
Préférence personnelle (Réglages) : désactivée, ne renvoie jamais rien.
|
||||
Renvoie des minutes depuis minuit du jour — possiblement ≥ 1440 quand le
|
||||
repos déborde sur les jours suivants (le jour courant est alors entièrement
|
||||
couvert) — ou None."""
|
||||
if not plages.get("repos_hebdo_active"):
|
||||
return None
|
||||
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
jour0_abs = (jour0 - _EPOCH_DATE).days * 1440
|
||||
fenetre_debut = jour0_abs - 6 * 1440
|
||||
fenetre_fin = jour0_abs + 2 * 1440
|
||||
actifs = [(max(deb, fenetre_debut), min(fin, fenetre_fin))
|
||||
for deb, fin in _activites_repos(p_by_date, astreintes, jour0)
|
||||
if fin > fenetre_debut and deb < fenetre_fin]
|
||||
if not actifs:
|
||||
return None
|
||||
fin_prec = None
|
||||
for deb, fin in actifs:
|
||||
trou_debut = fenetre_debut if fin_prec is None else fin_prec
|
||||
if deb - trou_debut >= REPOS_HEBDO_MIN:
|
||||
return None # repos hebdomadaire déjà pris dans la fenêtre glissante
|
||||
fin_prec = fin
|
||||
repos_fin = -(-(actifs[-1][1] + REPOS_HEBDO_MIN) // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
|
||||
rel = repos_fin - jour0_abs
|
||||
if rel < 1440 and rel <= hhmm_to_minutes(plages["matin_debut"]):
|
||||
return None # le repos s'achève avant l'ouverture : aucune contrainte
|
||||
return rel
|
||||
|
||||
|
||||
def retour_repos(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None:
|
||||
"""Heure de retour au bureau la plus tôt admise : repos quotidien de 11h
|
||||
(toujours) et, si activée, contrainte de repos hebdomadaire de 35h — la
|
||||
plus tardive des deux l'emporte. Minutes depuis minuit du jour, ≥ 1440 si
|
||||
le repos déborde sur les jours suivants, ou None."""
|
||||
candidats = [r for r in (
|
||||
retour_apres_repos(date_str, p_by_date, plages, astreintes),
|
||||
retour_repos_hebdo(date_str, p_by_date, plages, astreintes),
|
||||
) if r is not None]
|
||||
return max(candidats) if candidats else None
|
||||
|
||||
|
||||
def retour_txt(retour_min: int, date_str: str) -> str:
|
||||
"""« 13:00 », ou « Mar 07:00 » quand le retour déborde sur un jour suivant."""
|
||||
if retour_min < 1440:
|
||||
return minutes_to_time(retour_min)
|
||||
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
d = jour0 + timedelta(days=retour_min // 1440)
|
||||
return f"{JOURS_COURTS[d.weekday()]} {minutes_to_time(retour_min % 1440)}"
|
||||
|
||||
|
||||
def heures_travaillees(p: dict, retour_min: int | None = None) -> int:
|
||||
"""Return worked minutes for a pointage row.
|
||||
|
||||
@ -443,8 +557,12 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int,
|
||||
return max(0, du)
|
||||
|
||||
|
||||
def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None, feries: list[str] | set[str] | None = None) -> dict:
|
||||
"""Compute totals for a list of pointages (one week)."""
|
||||
def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None, feries: list[str] | set[str] | None = None, contexte: list[dict] | None = None) -> dict:
|
||||
"""Compute totals for a list of pointages (one week).
|
||||
|
||||
`contexte` : pointages des jours précédant la semaine (semaine ISO
|
||||
précédente en pratique) — la fenêtre glissante de 7 jours du repos
|
||||
hebdomadaire de 35h déborde sur la semaine d'avant."""
|
||||
plages = {**DEFAULT_PLAGES, **(plages or {})}
|
||||
astreintes = set(astreintes or [])
|
||||
feries = set(feries or [])
|
||||
@ -460,6 +578,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
total_du = 0
|
||||
jours = []
|
||||
p_by_date = {p["date"]: p for p in pointages}
|
||||
p_ctx = {r["date"]: r for r in (contexte or []) if r.get("date")}
|
||||
p_ctx.update(p_by_date)
|
||||
# Cumuls astreinte de la semaine, suivis à part : les heures astreinte ne
|
||||
# comptent ni dans travaille ni dans du (compteurs séparés pondérés).
|
||||
as_jours = []
|
||||
@ -471,9 +591,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
for p in pointages:
|
||||
conges_date = {c["type"] for c in conges if c["date"] == p["date"]}
|
||||
jour_ferie = p["date"] in feries
|
||||
# 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)
|
||||
# Retour au bureau après repos, calculé depuis l'heure de fin de la
|
||||
# dernière activité d'astreinte (11h quotidiennes) et, si activée, la
|
||||
# contrainte de repos hebdomadaire de 35h sur 7 jours glissants
|
||||
retour_min = retour_repos(p["date"], p_ctx, plages, astreintes)
|
||||
# Décompte astreinte du jour : événements multiples, durée réelle arrondie
|
||||
# au quart d'heure supérieur ; un férié marqué à la main qualifie les
|
||||
# minutes pour les règles « ferie »
|
||||
@ -526,7 +647,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
"astreinte_h": dec["total_h"] if dec else "",
|
||||
"astreinte_pondere_min": dec["total_pondere_min"] if dec else 0,
|
||||
"retour_min": retour_min,
|
||||
"retour": minutes_to_time(retour_min) if retour_min is not None else None,
|
||||
"retour": retour_txt(retour_min, p["date"]) if retour_min is not None else None,
|
||||
})
|
||||
# Jours repris dans le récapitulatif d'astreinte : ceux avec des heures
|
||||
# décomptées, mais aussi les jours AS déclarés portant un forfait (sans
|
||||
@ -628,6 +749,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
du = jour["du_min"]
|
||||
if du == 0:
|
||||
continue
|
||||
# Jour entièrement couvert par un repos hebdomadaire qui déborde
|
||||
# (retour au-delà de minuit suivant) : rien à planifier ce jour-là.
|
||||
if (jour.get("retour_min") or 0) >= 24 * 60:
|
||||
continue
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user