La dernière heure saisie qui clôt la semaine à +0h00 pile, sans
aucune demi-journée restante, déclenche une fête assumée : fond disco
en rotation de teintes, pluie d'une fourgonnée de confettis (emojis
et rectangles), trophée bondissant et titre doré défilant. Une seule
fois par semaine et par onglet, fermeture au clic ou après 8 s, et
animation réduite pour prefers-reduced-motion.
Le serveur pose un drapeau data-perfect (solde nul, plus rien à
remplir, heures dues non nulles) sur les soldes re-rendus à chaque
saisie et rafraîchissement ; le client le détecte et célèbre.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
1052 lines
54 KiB
Python
1052 lines
54 KiB
Python
"""Business logic: compute worked hours and expected hours per day/week."""
|
||
from datetime import date, datetime, timedelta
|
||
|
||
DEFAULT_PLAGES = {
|
||
"matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin
|
||
"matin_fin": "12:00", # présence obligatoire jusqu'à cette heure le matin
|
||
"aprem_debut": "14:00", # présence obligatoire à partir de cette heure l'après-midi
|
||
"aprem_fin": "17:00", # présence obligatoire jusqu'à cette heure l'après-midi (plancher de sortie)
|
||
"pause_dejeuner_fin": "13:30", # reprise désirée (indicative) : projetée par défaut tant que l'entrée aprem n'est pas saisie, ancrée à maintenant le jour même une fois passée
|
||
"arrivee_visee": "", # arrivée du matin visée pour les préconisations (vide = matin_debut)
|
||
"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
|
||
|
||
# Règles de comptage des heures d'astreinte : chaque minute comptée est
|
||
# attribuée à la première règle dont le type de jour et la fenêtre horaire la
|
||
# couvre, puis pondérée par son facteur. "jours" parmi : semaine, sam, dim, ferie.
|
||
DEFAULT_FACTEURS_ASTREINTE = [
|
||
{"nom": "Nuit", "jours": ["semaine", "sam", "dim", "ferie"], "debut": "20:00", "fin": "08:00", "facteur": 1.5},
|
||
{"nom": "Journée", "jours": ["semaine"], "debut": "08:00", "fin": "20:00", "facteur": 1.0},
|
||
{"nom": "Samedi", "jours": ["sam"], "debut": "08:00", "fin": "20:00", "facteur": 1.5},
|
||
{"nom": "Dimanche / férié", "jours": ["dim", "ferie"], "debut": "08:00", "fin": "20:00", "facteur": 2.0},
|
||
]
|
||
|
||
_EPOCH_DATE = date(2000, 1, 1)
|
||
JOURS_COURTS = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
|
||
|
||
|
||
def hhmm_to_minutes(val: str | None) -> int:
|
||
if not val:
|
||
return 0
|
||
h, m = val.split(":")
|
||
return int(h) * 60 + int(m)
|
||
|
||
|
||
def minutes_to_hhmm(minutes: int) -> str:
|
||
sign = "-" if minutes < 0 else ""
|
||
minutes = abs(minutes)
|
||
return f"{sign}{minutes // 60}h{minutes % 60:02d}"
|
||
|
||
|
||
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 fenetre_astreinte_jour(date_str: str, astreintes, feries=None) -> tuple[int, int] | None:
|
||
"""Bornes (minutes sur la timeline du jour, 0 = minuit du jour, > 1440 =
|
||
lendemain) de la période d'astreinte couvrant ce jour. Un jour déclaré AS
|
||
couvre 18:00 → 18:00 le lendemain : son propre soir à partir de 18:00, et le
|
||
lendemain jusqu'à 18:00 (le matin du jour AS lui-même n'est pas de
|
||
l'astreinte). Deux astreintes consécutives se recollent sans trou ni
|
||
chevauchement. Samedi, dimanche et jour férié (légal ou marqué à la main via
|
||
`feries`, dates « YYYY-MM-DD ») : la journée compte dès 0h00 — un jour AS
|
||
déclaré garde sa borne du lendemain 18:00 (interventions nocturnes), un
|
||
sam/dim/férié lendemain d'astreinte compte jusqu'à minuit.
|
||
None si le jour n'est pas qualifié."""
|
||
astreintes = set(astreintes or [])
|
||
jour_as = date_str in astreintes
|
||
veille_as = _veille(date_str) in astreintes
|
||
if not (jour_as or veille_as):
|
||
return None
|
||
d = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||
feries_eff = _jours_feries(d.year) | _jours_feries(d.year + 1)
|
||
for s in (feries or []):
|
||
try:
|
||
feries_eff.add(datetime.strptime(s, "%Y-%m-%d").date())
|
||
except (TypeError, ValueError):
|
||
continue
|
||
plein = d.weekday() >= 5 or d in feries_eff
|
||
debut = 0 if (veille_as or plein) else ASTREINTE_DEBUT_MIN
|
||
if jour_as:
|
||
fin = 24 * 60 + ASTREINTE_DEBUT_MIN
|
||
else:
|
||
fin = 24 * 60 if plein else ASTREINTE_DEBUT_MIN
|
||
return debut, fin
|
||
|
||
|
||
def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict, ferie: bool = False) -> 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. Un jour férié n'a aucune présence obligatoire."""
|
||
if retour_min is None or ferie:
|
||
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 couvert_detail(plages: dict, retour_min: int | None, matin_ex: int, aprem_ex: int) -> str:
|
||
"""Description des plages de présence obligatoire couvertes par le repos de
|
||
11h (affichée avec le crédit d'heures effectuées). N'apparaît que couvert > 0,
|
||
donc retour_min défini."""
|
||
parts = []
|
||
if matin_ex:
|
||
fin = minutes_to_time(min(hhmm_to_minutes(plages["matin_fin"]), retour_min or 0))
|
||
parts.append(f"matin {plages['matin_debut']}→{fin}")
|
||
if aprem_ex:
|
||
fin = minutes_to_time(min(hhmm_to_minutes(plages["aprem_fin"]), retour_min or 0))
|
||
parts.append(f"après-midi {plages['aprem_debut']}→{fin}")
|
||
return ", ".join(parts)
|
||
|
||
|
||
def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int,
|
||
ferie: bool = False) -> tuple[int, int]:
|
||
"""Nominaux (attendus) matin et aprem en minutes, après congés, jour férié et
|
||
exclusion liée à l'heure de retour."""
|
||
if ferie or "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)
|
||
|
||
|
||
# ── Jours fériés français (fixes + mobiles) ───────────────────────────────────
|
||
|
||
def _paques(annee: int) -> date:
|
||
"""Dimanche de Pâques (algorithme de Meeus/Jones/Butcher)."""
|
||
a = annee % 19
|
||
b, c = divmod(annee, 100)
|
||
d, e = divmod(b, 4)
|
||
f = (b + 8) // 25
|
||
g = (b - f + 1) // 3
|
||
h = (19 * a + b - d - g + 15) % 30
|
||
i, k = divmod(c, 4)
|
||
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||
m = (a + 11 * h + 22 * l) // 451
|
||
mois, jour = divmod(h + l - 7 * m + 114, 31)
|
||
return date(annee, mois, jour + 1)
|
||
|
||
|
||
def _jours_feries(annee: int) -> set[date]:
|
||
p = _paques(annee)
|
||
return {
|
||
date(annee, 1, 1), date(annee, 5, 1), date(annee, 5, 8),
|
||
date(annee, 7, 14), date(annee, 8, 15), date(annee, 11, 1),
|
||
date(annee, 11, 11), date(annee, 12, 25),
|
||
p + timedelta(days=1), # lundi de Pâques
|
||
p + timedelta(days=39), # Ascension
|
||
p + timedelta(days=50), # lundi de Pentecôte
|
||
}
|
||
|
||
|
||
def _categorie_jour(d: date, feries: set[date]) -> str:
|
||
if d in feries:
|
||
return "ferie"
|
||
return {5: "sam", 6: "dim"}.get(d.weekday(), "semaine")
|
||
|
||
|
||
def _dans_fenetre(minute: int, debut_min: int, fin_min: int) -> bool:
|
||
if debut_min <= fin_min:
|
||
return debut_min <= minute < fin_min
|
||
return minute >= debut_min or minute < fin_min # fenêtre à cheval sur minuit
|
||
|
||
|
||
def _facteur_txt(facteur: float) -> str:
|
||
return f"{facteur:.2f}".rstrip("0").rstrip(".").replace(".", ",")
|
||
|
||
|
||
def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]:
|
||
return [
|
||
{
|
||
"nom": str(r.get("nom") or "?"),
|
||
"jours": set(r.get("jours") or ()),
|
||
"debut": hhmm_to_minutes(r.get("debut")),
|
||
"fin": hhmm_to_minutes(r.get("fin")),
|
||
"facteur": float(r.get("facteur") or 1),
|
||
}
|
||
for r in (facteurs if facteurs else DEFAULT_FACTEURS_ASTREINTE)
|
||
]
|
||
|
||
|
||
MIDI_MIN = 12 * 60 # minute représentative de la journée (le forfait AS y est réputé réalisé)
|
||
|
||
|
||
def regle_journee_astreinte(date_str: str, facteurs: list[dict] | None = None,
|
||
feries_declares: list[str] | set[str] | None = None) -> dict | None:
|
||
"""Règle de bonification des heures de journée du jour donné : la première
|
||
règle dont le type de jour et la fenêtre horaire couvrent midi, minute
|
||
représentative de la journée. Le forfait d'astreinte est considéré réalisé
|
||
en journée du jour où il est attribué : c'est ce facteur qui le pondère
|
||
(semaine ×1, samedi ×1,5, dimanche/férié ×2 avec les règles par défaut).
|
||
None si aucune règle ne couvre midi ce jour-là (forfait non bonifié)."""
|
||
regles = _regles_astreinte(facteurs)
|
||
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
|
||
for s in (feries_declares or []):
|
||
try:
|
||
feries.add(datetime.strptime(s, "%Y-%m-%d").date())
|
||
except (TypeError, ValueError):
|
||
continue
|
||
cat = _categorie_jour(jour0, feries)
|
||
r = next((r for r in regles if cat in r["jours"] and _dans_fenetre(MIDI_MIN, r["debut"], r["fin"])), None)
|
||
if r is None:
|
||
return None
|
||
return {"nom": r["nom"], "facteur": r["facteur"], "facteur_txt": _facteur_txt(r["facteur"])}
|
||
|
||
|
||
def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None,
|
||
fenetre: tuple[int, int] | None = None,
|
||
feries_declares: list[str] | set[str] | None = None) -> dict:
|
||
"""Décompte des heures réalisées en astreinte pour un jour : la durée réelle
|
||
de chaque intervention est arrondie au quart d'heure supérieur (2, 5 ou
|
||
12 min réelles → 15 min ; 22 ou 28 → 30 min), même si l'arrondi ne tombe
|
||
pas juste sur un quart d'heure de l'horloge (:00, :15, :30, :45). Chaque
|
||
minute comptée est attribuée à la première règle (type de jour × fenêtre
|
||
horaire) qui la couvre, puis pondérée par son facteur. Les événements vivent
|
||
sur la timeline réelle : fin < début = intervention à cheval sur minuit,
|
||
dont la partie après minuit compte sur le jour suivant. Les minutes réelles
|
||
couvertes par plusieurs événements ne sont comptées qu'une fois, attribuées
|
||
au premier événement qui les couvre (ordre chronologique) : les compteurs
|
||
des événements somment au total du jour.
|
||
|
||
`fenetre` (bornes en minutes sur la timeline du jour, cf. fenetre_astreinte_jour)
|
||
borne la période d'astreinte : seules les minutes à l'intérieur comptent, les
|
||
événements ou fragments hors fenêtre sont ignorés. None = tout compter.
|
||
`feries_declares` (dates « YYYY-MM-DD » marquées à la main) complète les jours
|
||
fériés légaux calculés pour l'attribution des règles de type « ferie ».
|
||
|
||
Renvoie {tranches, total_min, total_h, total_pondere_min, total_pondere_h,
|
||
events: [détail par événement], categories: [compteurs par règle]}.
|
||
"""
|
||
regles = _regles_astreinte(facteurs)
|
||
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||
# Fériés légaux (calculés) + fériés marqués à la main : les deux qualifient
|
||
# les minutes pour les règles de type « ferie ».
|
||
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
|
||
for s in (feries_declares or []):
|
||
try:
|
||
feries.add(datetime.strptime(s, "%Y-%m-%d").date())
|
||
except (TypeError, ValueError):
|
||
continue
|
||
|
||
def _regle_de_la_minute(qm: int) -> dict | None:
|
||
cat_j = _categorie_jour(_EPOCH_DATE + timedelta(days=qm // 1440), feries)
|
||
minute = qm % 1440
|
||
return next((r for r in regles if cat_j in r["jours"] and _dans_fenetre(minute, r["debut"], r["fin"])), None)
|
||
|
||
vus: set[int] = set()
|
||
events_out = []
|
||
# Événements triés par début : une minute réelle couverte par plusieurs
|
||
# interventions qui se suivent ou se chevauchent n'est comptée qu'une fois,
|
||
# attribuée à la première.
|
||
parses = []
|
||
for e in events:
|
||
deb, fin = e.get("debut"), e.get("fin")
|
||
if not deb or not fin:
|
||
continue
|
||
deb_min, fin_min = hhmm_to_minutes(deb), hhmm_to_minutes(fin)
|
||
duree = fin_min - deb_min if fin_min >= deb_min else 24 * 60 - deb_min + fin_min
|
||
if fenetre is not None:
|
||
debut_c = max(deb_min, fenetre[0])
|
||
fin_c = min(deb_min + duree, fenetre[1])
|
||
if fin_c <= debut_c:
|
||
continue
|
||
deb_min, duree = debut_c, fin_c - debut_c
|
||
parses.append((e, deb, fin, deb_min, duree))
|
||
parses.sort(key=lambda t: (t[3], t[3] + t[4]))
|
||
for e, deb, fin, deb_min, duree in parses:
|
||
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
|
||
nouvelles = [qm for qm in range(a0, a0 + duree) if qm not in vus]
|
||
vus.update(range(a0, a0 + duree))
|
||
ev_reel = len(nouvelles)
|
||
# Durée réelle arrondie au quart d'heure supérieur : 2, 5 ou 12 min
|
||
# réelles → 15 ; 22 ou 28 → 30 — même hors des quarts d'horloge.
|
||
ev_min = -(-ev_reel // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
|
||
# La durée comptée court depuis la première minute non déjà couverte ;
|
||
# chaque minute comptée (réelle ou d'arrondi) rejoint sa règle.
|
||
arrondi0 = nouvelles[0] if nouvelles else a0
|
||
ev_cats: dict[str, dict] = {}
|
||
for qm in range(arrondi0, arrondi0 + ev_min):
|
||
regle = _regle_de_la_minute(qm)
|
||
nom = regle["nom"] if regle else "Autre"
|
||
facteur = regle["facteur"] if regle else 1.0
|
||
c = ev_cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "min": 0})
|
||
c["min"] += 1
|
||
ev_details = [
|
||
{**c, "min_h": minutes_to_hhmm(c["min"]),
|
||
"pondere_min": int(c["min"] * c["facteur"] + 0.5)}
|
||
for c in ev_cats.values()
|
||
]
|
||
ev_pondere = sum(d["pondere_min"] for d in ev_details)
|
||
events_out.append({
|
||
"debut": deb, "fin": fin, "desc": e.get("desc") or "",
|
||
"reel_min": ev_reel, "reel_h": minutes_to_hhmm(ev_reel),
|
||
"arrondi_debut": minutes_to_time(arrondi0 % 1440) if ev_min else "",
|
||
"arrondi_fin": minutes_to_time((arrondi0 + ev_min) % 1440) if ev_min else "",
|
||
"tranches": ev_min // PAS_ASTREINTE_MIN, "min": ev_min, "min_h": minutes_to_hhmm(ev_min),
|
||
"pondere_min": ev_pondere, "pondere_h": minutes_to_hhmm(ev_pondere),
|
||
"details": ev_details,
|
||
})
|
||
|
||
cats: dict[str, dict] = {}
|
||
for ev in events_out:
|
||
for d in ev["details"]:
|
||
c = cats.setdefault(d["nom"], {"nom": d["nom"], "facteur": d["facteur"], "facteur_txt": d["facteur_txt"], "min": 0})
|
||
c["min"] += d["min"]
|
||
categories = []
|
||
for nom, c in cats.items():
|
||
categories.append({**c, "min_h": minutes_to_hhmm(c["min"]),
|
||
"pondere_min": int(c["min"] * c["facteur"] + 0.5),
|
||
"pondere_h": minutes_to_hhmm(int(c["min"] * c["facteur"] + 0.5))})
|
||
ordre = [r["nom"] for r in regles] + ["Autre"]
|
||
categories.sort(key=lambda c: ordre.index(c["nom"]) if c["nom"] in ordre else len(ordre))
|
||
|
||
total_min = sum(e["min"] for e in events_out)
|
||
total_pondere = sum(c["pondere_min"] for c in categories)
|
||
return {
|
||
"tranches": total_min // PAS_ASTREINTE_MIN,
|
||
"total_min": total_min,
|
||
"total_h": minutes_to_hhmm(total_min),
|
||
"total_pondere_min": total_pondere,
|
||
"total_pondere_h": minutes_to_hhmm(total_pondere),
|
||
"events": events_out,
|
||
"categories": categories,
|
||
}
|
||
|
||
|
||
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.
|
||
|
||
Chaque activité d'astreinte est un événement {debut, fin} saisi sur le jour de
|
||
son début (les événements remplacent l'ancienne fin unique). fin < debut
|
||
désigne une intervention à cheval sur minuit, dont la fin (heure du lendemain)
|
||
termine l'activité. L'astreinte couvre 18:00 → 18:00 le lendemain — sauf
|
||
samedi, dimanche et jour férié, où la journée entière compte (0h00 → 0h00) :
|
||
hors ces jours, une intervention terminée avant 18:00 sur le jour déclaré
|
||
n'est pas de l'astreinte et ne déclenche pas de repos. Comme pour le décompte, la durée
|
||
réelle de l'intervention est arrondie au quart d'heure supérieur : la fin
|
||
comptée est le début + la durée arrondie. Le retour retombe ensuite sur un
|
||
quart d'heure d'horloge fixe (:00, :15, :30, :45), arrondi au supérieur :
|
||
fin 23:30 → retour 10:30 le lendemain ; 21:00 → 23:37 (durée comptée 2h45,
|
||
fin 23:45) → retour 10:45 ; 22:39 → 23:49 (durée comptée 1h15, fin comptée
|
||
23:54) → retour 11:00 ; fin 02:00 → retour 13:00 le jour même.
|
||
Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour
|
||
au-delà de l'ouverture."""
|
||
astreintes = set(astreintes or [])
|
||
veille = _veille(date_str)
|
||
cands = []
|
||
|
||
def _fins(date_op: str):
|
||
row = p_by_date.get(date_op) or {}
|
||
for e in row.get("astreinte_events") or []:
|
||
deb, fin = e.get("debut"), e.get("fin")
|
||
if deb and fin:
|
||
yield hhmm_to_minutes(deb), hhmm_to_minutes(fin)
|
||
|
||
def _fin_comptee(deb: int, fin: int) -> int:
|
||
"""Fin d'activité effectivement comptée (sur la timeline du jour du
|
||
début) : durée réelle arrondie au quart d'heure supérieur (2, 5 ou
|
||
12 min → 15 ; 22 ou 28 → 30), même hors des quarts d'horloge."""
|
||
duree = fin - deb if fin >= deb else 24 * 60 - deb + fin
|
||
return deb + -(-duree // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
|
||
|
||
# Événements du jour, fin matinale : le matin du jour n'est de l'astreinte
|
||
# que si la veille était déclarée AS (fenêtre 18:00→18:00) ; une fin matinale
|
||
# y termine une intervention nocturne et le repos s'achève aujourd'hui.
|
||
if veille in astreintes:
|
||
for deb, fin in _fins(date_str):
|
||
fin_c = _fin_comptee(deb, fin)
|
||
if fin >= deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
||
cands.append(fin_c + REPOS_QUOTIDIEN_MIN)
|
||
# Événements de la veille déclarée AS : fin de soirée (après 18:00, dans la
|
||
# fenêtre) → le repos se termine aujourd'hui ; fin après minuit (fin < deb) →
|
||
# l'activité s'est achevée aujourd'hui à fin, le repos s'achève donc
|
||
# aujourd'hui.
|
||
if veille in astreintes:
|
||
for deb, fin in _fins(veille):
|
||
fin_c = _fin_comptee(deb, fin)
|
||
if fin >= deb and fin_c > ASTREINTE_DEBUT_MIN and fin_c + REPOS_QUOTIDIEN_MIN >= 24 * 60:
|
||
cands.append(fin_c + REPOS_QUOTIDIEN_MIN - 24 * 60)
|
||
if fin < deb and fin_c - 24 * 60 + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
||
cands.append(fin_c - 24 * 60 + REPOS_QUOTIDIEN_MIN)
|
||
if not cands:
|
||
return None
|
||
best = max(cands)
|
||
# Le retour tombe sur un quart d'heure d'horloge fixe (:00, :15, :30, :45) :
|
||
# arrondi au quart supérieur — ex. fin comptée 23:54 + 11h = 10:54 → 11:00.
|
||
best = -(-best // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
|
||
# Un repos qui se termine avant l'ouverture ne contraint rien
|
||
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.
|
||
|
||
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). 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"]) - entree_min(p["matin_entree"]))
|
||
elif p["matin_entree"] and p.get("date") == today:
|
||
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"]) - entree_min(p["aprem_entree"]))
|
||
elif p["aprem_entree"] and p.get("date") == today:
|
||
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,
|
||
plages: dict | None = None, retour_min: int | None = None,
|
||
ferie: bool = False) -> int:
|
||
"""Return expected minutes for a date, accounting for vacations and public
|
||
holidays. 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 ou jour férié = no hours due
|
||
if d.weekday() >= 5 or ferie:
|
||
return 0
|
||
|
||
conges_date = {c["type"] for c in conges if c["date"] == date_str}
|
||
|
||
if "jour" in conges_date:
|
||
return 0
|
||
if "matin" in conges_date and "aprem" in conges_date:
|
||
return 0
|
||
|
||
half = heures_jour_min // 2
|
||
if "matin" in conges_date or "aprem" in conges_date:
|
||
du = half
|
||
else:
|
||
du = 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 _repartir(left: int, rooms: list[int]) -> tuple[list[int], int]:
|
||
"""Répartit équitablement `left` minutes sur des créneaux de capacités
|
||
`rooms` : chacun reçoit au plus sa capacité, à parts égales autant que
|
||
possible ; le surplus d'un créneau saturé se reporte sur les autres.
|
||
Renvoie (attributions par créneau, minutes restant à placer)."""
|
||
adjs = [0] * len(rooms)
|
||
while left > 0:
|
||
open_idx = [i for i, room in enumerate(rooms) if adjs[i] < room]
|
||
if not open_idx:
|
||
break
|
||
base, rem = divmod(left, len(open_idx))
|
||
for k, i in enumerate(open_idx):
|
||
give = min(rooms[i] - adjs[i], base + (1 if k < rem else 0))
|
||
adjs[i] += give
|
||
left -= give
|
||
return adjs, left
|
||
|
||
|
||
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 [])
|
||
forfait_as_min = hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0
|
||
matin_debut_min = hhmm_to_minutes(plages["matin_debut"])
|
||
matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
|
||
aprem_debut_min = hhmm_to_minutes(plages["aprem_debut"])
|
||
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
|
||
pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"])
|
||
arrivee_visee_min = hhmm_to_minutes(plages["arrivee_visee"]) if plages.get("arrivee_visee") else matin_debut_min
|
||
depart_vise_min = hhmm_to_minutes(plages["depart_vise"]) if plages.get("depart_vise") else None
|
||
|
||
total_travaille = 0
|
||
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 = []
|
||
as_total_min = 0
|
||
as_total_pondere_min = 0
|
||
as_forfait_min = 0
|
||
as_forfait_pondere_min = 0
|
||
as_cats: dict[str, dict] = {}
|
||
|
||
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 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 »
|
||
events = p.get("astreinte_events") or []
|
||
dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes, feries), feries)
|
||
if jour_astreinte_qualifie(p["date"], astreintes) and events else None)
|
||
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages, jour_ferie)
|
||
forfait = forfait_as_min if p["date"] in astreintes else 0
|
||
# Le forfait d'astreinte est considéré réalisé en journée du jour AS
|
||
# déclaré : il bénéficie de la bonification « journée » de ce jour
|
||
# (×1 en semaine, ×1,5 le samedi, ×2 le dimanche ou férié par défaut).
|
||
regle_journee = regle_journee_astreinte(p["date"], facteurs, feries) if forfait else None
|
||
forfait_pondere = int(forfait * (regle_journee or {"facteur": 1.0})["facteur"] + 0.5) if forfait else 0
|
||
# Le forfait d'astreinte ne compte jamais dans les heures travaillées :
|
||
# il est suivi à part dans le récapitulatif d'astreinte, pondéré par la
|
||
# bonification journée.
|
||
#
|
||
# Les heures de présence obligatoire couvertes par le repos de 11h
|
||
# (matin_ex + aprem_ex) sont comptées comme effectuées : créditées au
|
||
# travaillé du jour, les heures dues restent complètes. Le solde est le
|
||
# même qu'en soustrayant couvert du dû, mais le calcul est explicite :
|
||
# travaillé = pointé + couvert, dû = nominal complet. Sans heures dues
|
||
# (week-end, férié, congé) il n'y a rien à créditer, et le crédit ne
|
||
# dépasse jamais le nominal du jour.
|
||
du_full = heures_dues(p["date"], conges, heures_jour_min, plages, None, jour_ferie)
|
||
couvert = min(matin_ex + aprem_ex, du_full)
|
||
travaille = heures_travaillees(p, retour_min) + couvert
|
||
du = du_full
|
||
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie)
|
||
delta = travaille - du
|
||
total_travaille += travaille
|
||
total_du += du
|
||
jours.append({
|
||
**p,
|
||
"travaille_min": travaille,
|
||
"du_min": du,
|
||
"delta_min": delta,
|
||
"travaille": minutes_to_hhmm(travaille),
|
||
"du": minutes_to_hhmm(du),
|
||
"delta": minutes_to_hhmm(delta),
|
||
"conge": conges_date,
|
||
"ferie": jour_ferie,
|
||
"forfait_min": forfait,
|
||
"forfait_h": minutes_to_hhmm(forfait) if forfait else "",
|
||
"du_full_min": du_full,
|
||
"matin_excl_min": matin_ex,
|
||
"aprem_excl_min": aprem_ex,
|
||
"couvert_min": couvert,
|
||
"couvert_h": minutes_to_hhmm(couvert) if couvert else "",
|
||
"couvert_detail": couvert_detail(plages, retour_min, matin_ex, aprem_ex) if couvert else "",
|
||
"matin_nominal_min": matin_nom,
|
||
"aprem_nominal_min": aprem_nom,
|
||
"astreinte": p["date"] in astreintes,
|
||
"astreinte_qualifiee": jour_astreinte_qualifie(p["date"], astreintes),
|
||
"astreinte_min": dec["total_min"] if dec else 0,
|
||
"astreinte_h": dec["total_h"] if dec else "",
|
||
"astreinte_pondere_min": dec["total_pondere_min"] if dec else 0,
|
||
"retour_min": retour_min,
|
||
"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
|
||
# intervention) — le forfait y est rappelé, sans entrer dans les compteurs.
|
||
if (dec and dec["total_min"] > 0) or forfait:
|
||
as_jours.append({
|
||
"date": p["date"],
|
||
"jour": JOURS_COURTS[datetime.strptime(p["date"], "%Y-%m-%d").weekday()],
|
||
"events": dec["events"] if dec else [],
|
||
"total_min": dec["total_min"] if dec else 0,
|
||
"total_h": dec["total_h"] if dec else "",
|
||
"total_pondere_h": dec["total_pondere_h"] if dec else "",
|
||
"forfait_min": forfait,
|
||
"forfait_h": minutes_to_hhmm(forfait) if forfait else "",
|
||
"forfait_pondere_min": forfait_pondere,
|
||
"forfait_pondere_h": minutes_to_hhmm(forfait_pondere) if forfait_pondere else "",
|
||
"forfait_regle": regle_journee["nom"] if regle_journee else "",
|
||
"forfait_facteur_txt": regle_journee["facteur_txt"] if regle_journee else "1",
|
||
})
|
||
as_forfait_min += forfait
|
||
as_forfait_pondere_min += forfait_pondere
|
||
if dec and dec["total_min"] > 0:
|
||
as_total_min += dec["total_min"]
|
||
as_total_pondere_min += dec["total_pondere_min"]
|
||
for c in dec["categories"]:
|
||
fc = as_cats.setdefault(c["nom"], {"nom": c["nom"], "facteur": c["facteur"], "min": 0, "pondere_min": 0})
|
||
fc["min"] += c["min"]
|
||
fc["pondere_min"] += c["pondere_min"]
|
||
|
||
# Catégories astreinte fusionnées sur la semaine, dans l'ordre des règles.
|
||
ordre_as = [r["nom"] for r in _regles_astreinte(facteurs)] + ["Autre"]
|
||
as_categories = []
|
||
for nom in sorted(as_cats, key=lambda n: ordre_as.index(n) if n in ordre_as else len(ordre_as)):
|
||
c = as_cats[nom]
|
||
as_categories.append({
|
||
"nom": nom,
|
||
"facteur": c["facteur"],
|
||
"facteur_txt": _facteur_txt(c["facteur"]),
|
||
"min": c["min"],
|
||
"min_h": minutes_to_hhmm(c["min"]),
|
||
"pondere_min": c["pondere_min"],
|
||
"pondere_h": minutes_to_hhmm(c["pondere_min"]),
|
||
})
|
||
|
||
# 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 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
|
||
cumul = 0
|
||
cumul_chain_ok = True
|
||
for jour in jours:
|
||
cumul += jour["delta_min"]
|
||
jour["delta_cumul_min"] = cumul
|
||
jour["delta_cumul"] = minutes_to_hhmm(abs(cumul))
|
||
# Chain breaks on the first incomplete workday — subsequent days don't show cumul
|
||
if not jour["is_complete"] and jour["du_min"] > 0:
|
||
cumul_chain_ok = False
|
||
jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok
|
||
|
||
# Préconisations : répartit l'écart restant (retard à rattraper, ou avance à
|
||
# écouler) à parts égales sur les heures de départ du soir des demi-journées
|
||
# restantes, pour viser un solde à 0 en fin de semaine. En cas de retard, si
|
||
# les départs du soir ne suffisent pas (saturés à minuit), deux leviers
|
||
# supplémentaires entrent en jeu dans l'ordre, bornés par les plages de
|
||
# présence des préférences : avancer les arrivées du matin (jamais avant le
|
||
# début de « Présence matin », l'heure de retour après repos, ni avant
|
||
# maintenant pour le jour en cours), puis avancer les retours de midi (la
|
||
# pause peut être raccourcie, jamais avant la fin de « Présence matin »).
|
||
# En cas d'avance, cascade miroir : départs du soir avancés (jamais sous la
|
||
# fin de « Présence après-midi »), puis arrivées du matin retardées (jamais
|
||
# après le début de « Présence matin »), puis retours de midi retardés
|
||
# (jamais après le début de « Présence après-midi » : la pause s'allonge).
|
||
# Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi.
|
||
for jour in jours:
|
||
jour["cible_matin"] = None
|
||
jour["cible_aprem"] = None
|
||
jour["cible_matin_entree"] = None
|
||
jour["cible_aprem_entree"] = None
|
||
|
||
restant = total_du - total_travaille
|
||
|
||
today = datetime.today().strftime("%Y-%m-%d")
|
||
now_min = datetime.today().hour * 60 + datetime.today().minute
|
||
|
||
slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage
|
||
# restant_planif : reste à planifier. Une demi-journée en cours (entrée pointée
|
||
# sans sortie, aujourd'hui) est comptée ci-dessous comme un créneau entier à
|
||
# faire depuis son heure d'entrée ; son temps déjà écoulé, inclus dans
|
||
# total_travaille, doit donc être réintégré, sinon la même période compte à la
|
||
# fois comme travaillée et comme à venir. Sauf si le créneau est saturé (temps
|
||
# écoulé ≥ nominal) : la sortie cible retombe alors à « maintenant », la période
|
||
# n'est pas re-planifiée, et son excédent déjà travaillé est de facto compté —
|
||
# le réintégrer ferait doubler le compte.
|
||
restant_planif = restant
|
||
for jour in jours:
|
||
# Un jour passé non renseigné n'est pas exclu : cette appli permet de saisir
|
||
# ses heures après coup, donc une demi-journée vide reste "à faire" quelle
|
||
# que soit sa date.
|
||
cg = jour["conge"]
|
||
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
|
||
# Temps déjà écoulé des demi-journées en cours aujourd'hui (entrée pointée
|
||
# sans sortie) — cf. restant_planif ci-dessus.
|
||
ecoule = {}
|
||
if jour["date"] == today:
|
||
retour = jour.get("retour_min") or 0
|
||
for half in ("matin", "aprem"):
|
||
if jour.get(f"{half}_entree") and not jour.get(f"{half}_sortie"):
|
||
ecoule[half] = max(0, now_min - max(hhmm_to_minutes(jour[f"{half}_entree"]), retour))
|
||
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:
|
||
# 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
|
||
# du même jour (s'il est encore ouvert), sinon dans le rattrapage global.
|
||
surplus = max(0, matin_nominal - cap)
|
||
if surplus and not aprem_done:
|
||
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.
|
||
# Une demi-journée en cours garde toujours son créneau : l'entrée ouverte
|
||
# doit recevoir une heure de sortie, ne serait-ce que « maintenant ».
|
||
if (cap > 0 and matin_nominal > 0) or "matin" in ecoule:
|
||
nominal = min(matin_nominal, cap)
|
||
sature = "matin" in ecoule and ecoule["matin"] >= nominal
|
||
if "matin" in ecoule and not sature:
|
||
restant_planif += ecoule["matin"]
|
||
slots.append({"jour": jour, "half": "matin", "nominal": nominal, "debut": debut,
|
||
"sature": sature})
|
||
if not aprem_done:
|
||
if jour.get("aprem_entree"):
|
||
debut = hhmm_to_minutes(jour["aprem_entree"])
|
||
else:
|
||
# La reprise de pause n'est qu'une heure désirée (indicative) :
|
||
# projection par défaut, bornée par l'heure de retour après repos.
|
||
# Le jour même, une fois l'heure passée, la reprise visée est
|
||
# ancrée à maintenant — on ne recommande pas de reprendre dans
|
||
# le passé (l'entrée restera saisissable après coup à la main).
|
||
debut = max(pause_dejeuner_fin_min, jour["retour_min"] or 0)
|
||
if jour["date"] == today:
|
||
debut = max(debut, now_min)
|
||
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,
|
||
# sans descendre sous le plancher aprem_fin (présence minimale obligatoire).
|
||
floor_room = max(0, (debut + aprem_nominal) - aprem_fin_min)
|
||
sature = "aprem" in ecoule and ecoule["aprem"] >= aprem_nominal
|
||
if "aprem" in ecoule and not sature:
|
||
restant_planif += ecoule["aprem"]
|
||
slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, "debut": debut,
|
||
"cap": cap, "cap_pref": cap_pref, "floor_room": floor_room,
|
||
"sature": sature})
|
||
|
||
slots_restants = len(slots)
|
||
# Les créneaux saturés sortent du plan : leur sortie cible est « maintenant »,
|
||
# ils ne peuvent rien rattraper ni écouler.
|
||
ecart_total = (restant_planif - sum(s["nominal"] for s in slots if not s.get("sature"))) if slots else 0
|
||
aprem_slots = [s for s in slots if s["half"] == "aprem"]
|
||
adjs = [0] * len(aprem_slots)
|
||
|
||
# Répartition équitable de l'écart (retard ou avance), plafonnée par la capacité de
|
||
# chaque créneau du soir, en deux passes en cas de retard : d'abord dans le cadre visé
|
||
# (avant le départ du soir), puis au-delà si ça ne tient pas. Le surplus d'un créneau
|
||
# saturé se reporte sur les autres. En cas d'avance, on avance les départs symétriquement,
|
||
# sans jamais descendre sous le plancher aprem_fin (une seule passe, pas de plafond souple).
|
||
left = 0
|
||
left_avance = 0
|
||
if ecart_total > 0:
|
||
left = ecart_total
|
||
for cap_key in ("cap_pref", "cap"):
|
||
while left > 0:
|
||
open_idx = [i for i, s in enumerate(aprem_slots)
|
||
if not s.get("sature") and adjs[i] < s[cap_key] - s["nominal"]]
|
||
if not open_idx:
|
||
break # tous les créneaux saturés à ce plafond : passe suivante, ou rattrapage impossible
|
||
base, rem = divmod(left, len(open_idx))
|
||
for k, i in enumerate(open_idx):
|
||
give = min(aprem_slots[i][cap_key] - aprem_slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
|
||
adjs[i] += give
|
||
left -= give
|
||
elif ecart_total < 0:
|
||
left_avance = -ecart_total
|
||
while left_avance > 0:
|
||
open_idx = [i for i, s in enumerate(aprem_slots)
|
||
if not s.get("sature") and -adjs[i] < s["floor_room"]]
|
||
if not open_idx:
|
||
break # tous les créneaux déjà au plancher : l'avance restante n'est pas plaçable
|
||
base, rem = divmod(left_avance, len(open_idx))
|
||
for k, i in enumerate(open_idx):
|
||
take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0))
|
||
adjs[i] -= take
|
||
left_avance -= take
|
||
# Si les départs du soir ne suffisent pas à absorber le retard, deux leviers
|
||
# supplémentaires dans l'ordre, bornés par les plages de présence : avancer
|
||
# les arrivées du matin (jamais avant le début de « Présence matin »), puis
|
||
# les retours de midi (jamais avant la fin de « Présence matin », ni avant
|
||
# la sortie réelle du matin s'il est déjà pointé). Une entrée déjà pointée
|
||
# n'est pas modifiable ; le jour en cours ne vise pas une heure passée.
|
||
arrivee_advs: dict[int, int] = {} # id(slot matin) → minutes d'avance à l'arrivée
|
||
reprise_advs: dict[int, int] = {} # id(slot aprem) → minutes d'avance au retour de midi
|
||
if left > 0:
|
||
candidats = []
|
||
for s in slots:
|
||
if s["half"] != "matin" or s["jour"].get("matin_entree"):
|
||
continue # arrivée déjà pointée : non modifiable
|
||
floor = max(matin_debut_min, s["jour"].get("retour_min") or 0)
|
||
if s["jour"]["date"] == today:
|
||
floor = max(floor, now_min)
|
||
room = max(0, s["debut"] - floor)
|
||
if room:
|
||
candidats.append((id(s), room))
|
||
gains, left = _repartir(left, [room for _, room in candidats])
|
||
arrivee_advs = {key: gain for (key, _), gain in zip(candidats, gains)}
|
||
if left > 0:
|
||
candidats = []
|
||
for s in aprem_slots:
|
||
j = s["jour"]
|
||
if j.get("aprem_entree"):
|
||
continue # reprise déjà pointée : non modifiable
|
||
# Plancher : fin de « Présence matin » — la pause de midi (entre les
|
||
# deux présences) peut être raccourcie jusqu'à zéro à l'extrême, mais
|
||
# le retour n'est jamais proposé avant la fin du matin réel.
|
||
floor = matin_fin_min
|
||
if j.get("matin_sortie"):
|
||
floor = max(floor, hhmm_to_minutes(j["matin_sortie"]))
|
||
if j["date"] == today:
|
||
floor = max(floor, now_min)
|
||
room = max(0, s["debut"] - floor)
|
||
if room:
|
||
candidats.append((id(s), room))
|
||
gains, left = _repartir(left, [room for _, room in candidats])
|
||
reprise_advs = {key: gain for (key, _), gain in zip(candidats, gains)}
|
||
|
||
# Miroir pour l'avance : si les départs du soir ne peuvent pas tout écouler
|
||
# (plancher aprem_fin), retarder d'abord les arrivées du matin (jamais après
|
||
# le début de « Présence matin »), puis les retours de midi (jamais après
|
||
# le début de « Présence après-midi ») — la pause de midi s'allonge
|
||
# d'autant. Une entrée déjà pointée n'est pas modifiable ; le jour en cours
|
||
# ne vise pas une heure déjà passée.
|
||
arrivee_delays: dict[int, int] = {} # id(slot matin) → minutes de retard à l'arrivée
|
||
reprise_delays: dict[int, int] = {} # id(slot aprem) → minutes de retard au retour de midi
|
||
if left_avance > 0:
|
||
candidats = []
|
||
for s in slots:
|
||
if s["half"] != "matin" or s["jour"].get("matin_entree"):
|
||
continue # arrivée déjà pointée : non modifiable
|
||
base = max(s["debut"], now_min) if s["jour"]["date"] == today else s["debut"]
|
||
room = max(0, matin_debut_min - base)
|
||
if room:
|
||
candidats.append((id(s), room))
|
||
gains, left_avance = _repartir(left_avance, [room for _, room in candidats])
|
||
arrivee_delays = {key: gain for (key, _), gain in zip(candidats, gains)}
|
||
if left_avance > 0:
|
||
candidats = []
|
||
for s in aprem_slots:
|
||
j = s["jour"]
|
||
if j.get("aprem_entree"):
|
||
continue # reprise déjà pointée : non modifiable
|
||
base = max(s["debut"], now_min) if j["date"] == today else s["debut"]
|
||
room = max(0, aprem_debut_min - base)
|
||
if room:
|
||
candidats.append((id(s), room))
|
||
gains, left_avance = _repartir(left_avance, [room for _, room in candidats])
|
||
reprise_delays = {key: gain for (key, _), gain in zip(candidats, gains)}
|
||
|
||
def _en_cours(jour, half: str) -> bool:
|
||
"""True si cette demi-journée est déjà commencée aujourd'hui (entrée pointée,
|
||
pas encore de sortie) : la cible ne peut alors pas retomber avant l'heure
|
||
actuelle, sinon on recommanderait un départ déjà passé."""
|
||
return (
|
||
jour["date"] == today
|
||
and bool(jour.get(f"{half}_entree"))
|
||
and not jour.get(f"{half}_sortie")
|
||
)
|
||
|
||
# Le matin propose son horaire nominal (jamais ajusté par le rattrapage
|
||
# hebdomadaire) ; l'après-midi propose son horaire nominal éventuellement décalé
|
||
# par l'écart (retard ou avance) réparti ci-dessus. En rattrapage extrême, les
|
||
# arrivées du matin et les retours de midi peuvent être avancés (ci-dessus).
|
||
for s in slots:
|
||
if s["half"] != "matin":
|
||
continue
|
||
# Pas de plancher matin_fin ici : le nominal est déjà borné par cap (≤ matin_fin
|
||
# - debut), donc debut + nominal ne peut pas dépasser matin_fin de lui-même.
|
||
sortie_min = s["debut"] + s["nominal"]
|
||
if _en_cours(s["jour"], "matin"):
|
||
sortie_min = max(sortie_min, now_min)
|
||
entree = minutes_to_time(s["debut"] - arrivee_advs.get(id(s), 0) + arrivee_delays.get(id(s), 0))
|
||
s["jour"]["cible_matin_entree"] = entree
|
||
s["jour"]["cible_matin"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||
|
||
for i, s in enumerate(aprem_slots):
|
||
sortie_min = max(s["debut"] + s["nominal"] + adjs[i], aprem_fin_min)
|
||
if _en_cours(s["jour"], "aprem"):
|
||
sortie_min = max(sortie_min, now_min)
|
||
entree = minutes_to_time(s["debut"] - reprise_advs.get(id(s), 0) + reprise_delays.get(id(s), 0))
|
||
s["jour"]["cible_aprem_entree"] = entree
|
||
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||
|
||
extra_total = max(0, ecart_total)
|
||
avance_total = max(0, -ecart_total)
|
||
reste_min = max(0, restant)
|
||
|
||
return {
|
||
"jours": jours,
|
||
"total_travaille": minutes_to_hhmm(total_travaille),
|
||
"total_du": minutes_to_hhmm(total_du),
|
||
"total_du_min": total_du,
|
||
"solde": minutes_to_hhmm(total_travaille - total_du),
|
||
"solde_min": total_travaille - total_du,
|
||
"reste_min": reste_min,
|
||
"reste": minutes_to_hhmm(reste_min),
|
||
"extra_min": extra_total,
|
||
"extra": minutes_to_hhmm(extra_total),
|
||
"avance_min": avance_total,
|
||
"avance": minutes_to_hhmm(avance_total),
|
||
"slots_restants": slots_restants,
|
||
"moyenne_extra_min": (extra_total // slots_restants) if slots_restants else 0,
|
||
"moyenne_extra": minutes_to_hhmm((extra_total // slots_restants) if slots_restants else 0),
|
||
"astreinte": {
|
||
"actif": as_total_min > 0 or as_forfait_min > 0,
|
||
"total_min": as_total_min,
|
||
"total_h": minutes_to_hhmm(as_total_min),
|
||
"total_pondere_min": as_total_pondere_min,
|
||
"total_pondere_h": minutes_to_hhmm(as_total_pondere_min),
|
||
"total_forfait_min": as_forfait_min,
|
||
"total_forfait_h": minutes_to_hhmm(as_forfait_min),
|
||
"total_forfait_pondere_min": as_forfait_pondere_min,
|
||
"total_forfait_pondere_h": minutes_to_hhmm(as_forfait_pondere_min),
|
||
"categories": as_categories,
|
||
"jours": as_jours,
|
||
},
|
||
}
|