Le rattrapage hebdomadaire ne portait que sur le retard et pouvait s'étaler sur le matin quand il avait de la marge. Objectif demandé : pouvoir planifier les horaires à l'avance pour arriver à 0 en fin de semaine, en ajustant uniquement les heures de départ du soir - dans un sens (retard) comme dans l'autre (avance, plafonnée par aprem_fin). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
248 lines
12 KiB
Python
248 lines
12 KiB
Python
"""Business logic: compute worked hours and expected hours per day/week."""
|
|
from datetime import datetime
|
|
|
|
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)
|
|
}
|
|
|
|
|
|
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 heures_travaillees(p: dict) -> int:
|
|
"""Return worked minutes for a pointage row.
|
|
|
|
If a half-day has started but has no exit time yet, count elapsed time up to now
|
|
(today only — past dates with missing exits are treated as 0).
|
|
"""
|
|
today = datetime.today().strftime("%Y-%m-%d")
|
|
now_min = datetime.today().hour * 60 + datetime.today().minute
|
|
|
|
matin = 0
|
|
if p["matin_entree"] and p["matin_sortie"]:
|
|
matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - hhmm_to_minutes(p["matin_entree"]))
|
|
elif p["matin_entree"] and p.get("date") == today:
|
|
matin = max(0, now_min - hhmm_to_minutes(p["matin_entree"]))
|
|
|
|
aprem = 0
|
|
if p["aprem_entree"] and p["aprem_sortie"]:
|
|
aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - hhmm_to_minutes(p["aprem_entree"]))
|
|
elif p["aprem_entree"] and p.get("date") == today:
|
|
aprem = max(0, now_min - hhmm_to_minutes(p["aprem_entree"]))
|
|
|
|
return matin + aprem
|
|
|
|
|
|
def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int) -> int:
|
|
"""Return expected minutes for a date, accounting for vacations."""
|
|
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:
|
|
return half
|
|
|
|
return heures_jour_min
|
|
|
|
|
|
def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None) -> dict:
|
|
"""Compute totals for a list of pointages (one week)."""
|
|
plages = {**DEFAULT_PLAGES, **(plages 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 = []
|
|
|
|
for p in pointages:
|
|
travaille = heures_travaillees(p)
|
|
du = heures_dues(p["date"], conges, heures_jour_min)
|
|
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": {c["type"] for c in conges if c["date"] == p["date"]},
|
|
})
|
|
|
|
# Mark each day as complete (all non-congé slots filled, or day is a congé/weekend)
|
|
for jour in jours:
|
|
cg = jour["conge"]
|
|
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg
|
|
aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg
|
|
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
|
|
|
|
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
|
|
if jour.get("matin_entree") and not jour.get("matin_sortie"):
|
|
restant_planif += max(0, now_min - hhmm_to_minutes(jour["matin_entree"]))
|
|
if jour.get("aprem_entree") and not jour.get("aprem_sortie"):
|
|
restant_planif += max(0, now_min - hhmm_to_minutes(jour["aprem_entree"]))
|
|
|
|
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 = 0 if "matin" in cg else (du if "aprem" in cg else du // 2)
|
|
aprem_nominal = 0 if "aprem" in cg else (du if "matin" in cg else du - du // 2)
|
|
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg
|
|
aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg
|
|
if not matin_done:
|
|
debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else arrivee_visee_min
|
|
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.
|
|
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
|
|
if not aprem_done:
|
|
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
|
|
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
|
|
|
|
for i, s in enumerate(aprem_slots):
|
|
if adjs[i] == 0:
|
|
continue # rythme normal suffit pour cette demi-journée, rien à signaler
|
|
sortie_min = s["debut"] + s["nominal"] + adjs[i]
|
|
sortie_min = max(sortie_min, aprem_fin_min)
|
|
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),
|
|
}
|