Une avance que les départs du soir ne peuvent pas écouler (plancher de
présence) s'écoule désormais en cascade miroir du rattrapage : arrivées
du matin retardées, puis retours de midi, toujours bornés par les plages
de présence.
Une demi-journée en cours qui dépasse son nominal (ex. sortie de midi
oubliée) comptait son temps écoulé deux fois : suivre les cibles
sur-livrait jusqu'à 1h45 par semaine. Le créneau saturé propose
maintenant « sortir maintenant » et l'écart se reporte sur les autres
créneaux ; plus de retard fantôme affiché.
Le README liste désormais l'ensemble des règles de préconisation.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
1041 lines
54 KiB
Python
1041 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 par défaut projetée tant que l'entrée aprem n'est pas saisie
|
||
"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:
|
||
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,
|
||
# 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),
|
||
"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,
|
||
},
|
||
}
|