La saisie astreinte devient une liste d'événements (début, fin, description,
nombre illimité, fin antérieure au début = intervention à cheval sur minuit).
Ces heures ne comptent jamais dans la semaine : elles sont décomptées à part
par pas de 15 minutes, toute tranche entamée comptant en totalité, et rangées
dans des compteurs selon le jour et l'heure (Nuit ×1,5, Journée semaine ×1,
Samedi ×1,5, Dimanche/férié ×2 ; jours fériés français calculés
automatiquement, règles ajustables dans config.json via astreinte_facteurs).
Le détail par événement et les totaux pondérés s'affichent sous le bandeau de
la semaine, et les exports .ods gagnent les colonnes Astreinte brute et
pondérée. Le retour au bureau (fin du dernier événement + 11h de repos) est
désormais dérivé des événements.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
610 lines
29 KiB
Python
610 lines
29 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)
|
||
}
|
||
|
||
REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau
|
||
PAS_ASTREINTE_MIN = 15 # pas du décompte astreinte : toute tranche entamée compte en totalité
|
||
|
||
# Règles de comptage des heures d'astreinte : chaque tranche de 15 min est
|
||
# attribuée à la première règle dont le type de jour et la fenêtre horaire la
|
||
# couvrent, 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)
|
||
|
||
|
||
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 exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) -> tuple[int, int]:
|
||
"""Présence obligatoire (matin, aprem) tombant avant l'heure de retour au bureau,
|
||
en minutes : c'est le temps de présence retiré du décompte de la semaine quand le
|
||
repos de 11h décale l'arrivée. Les demi-journées en congé ne sont pas décomptées
|
||
deux fois."""
|
||
if retour_min is None:
|
||
return 0, 0
|
||
matin_debut = hhmm_to_minutes(plages["matin_debut"])
|
||
matin_fin = hhmm_to_minutes(plages["matin_fin"])
|
||
aprem_debut = hhmm_to_minutes(plages["aprem_debut"])
|
||
aprem_fin = hhmm_to_minutes(plages["aprem_fin"])
|
||
matin_ex = 0 if ("matin" in conges_date or "jour" in conges_date) else max(0, min(matin_fin, retour_min) - matin_debut)
|
||
aprem_ex = 0 if ("aprem" in conges_date or "jour" in conges_date) else max(0, min(aprem_fin, retour_min) - aprem_debut)
|
||
return matin_ex, aprem_ex
|
||
|
||
|
||
def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int) -> tuple[int, int]:
|
||
"""Nominaux (attendus) matin et aprem en minutes, après congés et exclusion liée
|
||
à l'heure de retour."""
|
||
if "jour" in conges_date:
|
||
m = a = 0
|
||
elif "matin" in conges_date:
|
||
m, a = 0, du_full_min
|
||
elif "aprem" in conges_date:
|
||
m, a = du_full_min, 0
|
||
else:
|
||
m, a = du_full_min // 2, du_full_min - du_full_min // 2
|
||
return max(0, m - matin_excl_min), max(0, a - aprem_excl_min)
|
||
|
||
|
||
# ── 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)
|
||
]
|
||
|
||
|
||
def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None) -> dict:
|
||
"""Décompte des heures réalisées en astreinte pour un jour, par pas de 15 min :
|
||
toute tranche entamée est comptée en totalité. Chaque tranche 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 tranches couvertes par plusieurs événements ne sont comptées qu'une
|
||
fois dans les totaux du jour.
|
||
|
||
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()
|
||
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
|
||
|
||
def _regle_du_quart(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)
|
||
|
||
# Attribution de chaque tranche (minute absolue alignée sur le pas) à sa règle ;
|
||
# la première affectation gagne si des événements se chevauchent.
|
||
quart_regles: dict[int, dict | None] = {}
|
||
events_out = []
|
||
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
|
||
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
|
||
quarts = list(range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
|
||
a0 + duree, PAS_ASTREINTE_MIN))
|
||
for qm in quarts:
|
||
quart_regles.setdefault(qm, _regle_du_quart(qm))
|
||
ev_cats: dict[str, dict] = {}
|
||
for qm in quarts:
|
||
regle = quart_regles.get(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), "tranches": 0})
|
||
c["tranches"] += 1
|
||
ev_min = len(quarts) * PAS_ASTREINTE_MIN
|
||
ev_details = [
|
||
{**c, "min": c["tranches"] * PAS_ASTREINTE_MIN,
|
||
"min_h": minutes_to_hhmm(c["tranches"] * PAS_ASTREINTE_MIN),
|
||
"pondere_min": int(c["tranches"] * PAS_ASTREINTE_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 "",
|
||
"tranches": len(quarts), "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 qm, regle in quart_regles.items():
|
||
nom = regle["nom"] if regle else "Autre"
|
||
facteur = regle["facteur"] if regle else 1.0
|
||
c = cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "tranches": 0})
|
||
c["tranches"] += 1
|
||
categories = []
|
||
for nom, c in cats.items():
|
||
cmin = c["tranches"] * PAS_ASTREINTE_MIN
|
||
categories.append({**c, "min": cmin, "min_h": minutes_to_hhmm(cmin),
|
||
"pondere_min": int(cmin * c["facteur"] + 0.5),
|
||
"pondere_h": minutes_to_hhmm(int(cmin * 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 = len(quart_regles) * PAS_ASTREINTE_MIN
|
||
total_pondere = sum(c["pondere_min"] for c in categories)
|
||
return {
|
||
"tranches": len(quart_regles),
|
||
"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é. Le retour s'applique au jour où se termine le repos : fin
|
||
23:30 → retour 10:30 le lendemain ; 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)
|
||
|
||
# Événements du jour : activité terminée aujourd'hui, le repos s'achève donc
|
||
# aujourd'hui (une fin matinale est la fin d'une intervention nocturne du jour).
|
||
if jour_astreinte_qualifie(date_str, astreintes):
|
||
for deb, fin in _fins(date_str):
|
||
if fin >= deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
||
cands.append(fin + REPOS_QUOTIDIEN_MIN)
|
||
# Événements de la veille : fin de soirée → 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 jour_astreinte_qualifie(veille, astreintes):
|
||
for deb, fin in _fins(veille):
|
||
if fin >= deb and fin + REPOS_QUOTIDIEN_MIN >= 24 * 60:
|
||
cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60)
|
||
if fin < deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60:
|
||
cands.append(fin + REPOS_QUOTIDIEN_MIN)
|
||
if not cands:
|
||
return None
|
||
best = max(cands)
|
||
# Un repos qui se termine avant l'ouverture ne contraint rien
|
||
return best if best > hhmm_to_minutes(plages["matin_debut"]) else None
|
||
|
||
|
||
def heures_travaillees(p: dict, retour_min: int | None = None) -> int:
|
||
"""Return worked minutes for a pointage row.
|
||
|
||
If a half-day has started but has no exit time yet, count elapsed time up to now
|
||
(today only — past dates with missing exits are treated as 0). 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) -> int:
|
||
"""Return expected minutes for a date, accounting for vacations. Si une heure de
|
||
retour est définie (repos de 11h après astreinte), la présence obligatoire avant
|
||
cette heure est retirée du décompte de la semaine."""
|
||
d = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||
# Weekend = no hours due
|
||
if d.weekday() >= 5:
|
||
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 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) -> dict:
|
||
"""Compute totals for a list of pointages (one week)."""
|
||
plages = {**DEFAULT_PLAGES, **(plages or {})}
|
||
astreintes = set(astreintes or [])
|
||
matin_debut_min = hhmm_to_minutes(plages["matin_debut"])
|
||
matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
|
||
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
|
||
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}
|
||
# 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_cats: dict[str, dict] = {}
|
||
|
||
for p in pointages:
|
||
conges_date = {c["type"] for c in conges if c["date"] == p["date"]}
|
||
# Retour au bureau après 11h de repos, calculé depuis l'heure de fin de la
|
||
# dernière activité d'astreinte (saisie sur le jour ou la veille qualifiés)
|
||
retour_min = retour_apres_repos(p["date"], p_by_date, plages, astreintes)
|
||
# Décompte astreinte du jour : événements multiples, pas de 15 min entamées
|
||
events = p.get("astreinte_events") or []
|
||
dec = decompte_astreinte(p["date"], events, facteurs) if (jour_astreinte_qualifie(p["date"], astreintes) and events) else None
|
||
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages)
|
||
travaille = heures_travaillees(p, retour_min)
|
||
du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min)
|
||
du_full = du + matin_ex + aprem_ex
|
||
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex)
|
||
delta = travaille - du
|
||
total_travaille += travaille
|
||
total_du += du
|
||
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,
|
||
"du_full_min": du_full,
|
||
"matin_excl_min": matin_ex,
|
||
"aprem_excl_min": aprem_ex,
|
||
"matin_nominal_min": matin_nom,
|
||
"aprem_nominal_min": aprem_nom,
|
||
"astreinte": p["date"] in astreintes,
|
||
"astreinte_qualifiee": jour_astreinte_qualifie(p["date"], astreintes),
|
||
"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": minutes_to_time(retour_min) if retour_min is not None else None,
|
||
})
|
||
if dec and dec["total_min"] > 0:
|
||
as_jours.append({"date": p["date"], "events": dec["events"]})
|
||
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"], "tranches": 0, "min": 0, "pondere_min": 0})
|
||
fc["tranches"] += c["tranches"]
|
||
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"]),
|
||
"tranches": c["tranches"],
|
||
"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. Le matin n'est jamais
|
||
# ajusté : la pause de midi (matin_fin → reprise, 12:00 → 13:30 par défaut) est
|
||
# intouchable, et le nominal matin qui n'y tient pas bascule sur l'après-midi du
|
||
# même jour. L'après-midi vise le départ du soir configuré (plafond souple :
|
||
# dépassé seulement si le rattrapage ne tient nulle part avant), avec minuit
|
||
# comme limite dure en cas de retard, ou aprem_fin comme plancher en cas d'avance.
|
||
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
|
||
|
||
# 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é au besoin,
|
||
# sinon la même période compte à la fois comme travaillée et comme à venir.
|
||
today = datetime.today().strftime("%Y-%m-%d")
|
||
now_min = datetime.today().hour * 60 + datetime.today().minute
|
||
restant_planif = restant
|
||
for jour in jours:
|
||
if jour["date"] != today:
|
||
continue
|
||
retour = jour.get("retour_min") or 0
|
||
if jour.get("matin_entree") and not jour.get("matin_sortie"):
|
||
restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["matin_entree"]), retour))
|
||
if jour.get("aprem_entree") and not jour.get("aprem_sortie"):
|
||
restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["aprem_entree"]), retour))
|
||
|
||
slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage
|
||
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
|
||
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.
|
||
if cap > 0 and matin_nominal > 0:
|
||
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
|
||
if not aprem_done:
|
||
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else 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)
|
||
slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, "debut": debut,
|
||
"cap": cap, "cap_pref": cap_pref, "floor_room": floor_room})
|
||
|
||
slots_restants = len(slots)
|
||
ecart_total = (restant_planif - sum(s["nominal"] for s in slots)) 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).
|
||
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 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 = -ecart_total
|
||
while left > 0:
|
||
open_idx = [i for i, s in enumerate(aprem_slots) if -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, 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 -= take
|
||
|
||
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 toujours 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.
|
||
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 = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}"
|
||
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 = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}"
|
||
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,
|
||
"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),
|
||
"categories": as_categories,
|
||
"jours": as_jours,
|
||
},
|
||
}
|