"""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 le manque restant à parts égales sur les demi-journées # restantes, dans la limite de la capacité de chaque créneau. La pause de midi # (matin_fin → reprise, 12:00 → 13:30 par défaut) est intouchable : la cible # matin ne dépasse jamais matin_fin, et le nominal matin qui n'y tient pas # bascule sur l'après-midi du même jour. Le matin démarre à l'arrivée visée # (sinon matin_debut) ; 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. 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 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} — cap = durée maximale du créneau 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 slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut, "cap": cap, "cap_pref": cap}) 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 slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, "debut": debut, "cap": cap, "cap_pref": cap_pref}) extra_total = max(0, restant_planif - sum(s["nominal"] for s in slots)) if slots else 0 slots_restants = len(slots) # Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau, # en deux passes : 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. adjs = [0] * slots_restants left = extra_total for cap_key in ("cap_pref", "cap"): while left > 0: open_idx = [i for i, s in enumerate(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(slots[i][cap_key] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0)) adjs[i] += give left -= give for i, s in enumerate(slots): sortie_min = s["debut"] + s["nominal"] + adjs[i] sortie_min = max(sortie_min, matin_fin_min if s["half"] == "matin" else aprem_fin_min) entree = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}" sortie = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" if s["half"] == "matin": s["jour"]["cible_matin_entree"] = entree s["jour"]["cible_matin"] = sortie else: s["jour"]["cible_aprem_entree"] = entree s["jour"]["cible_aprem"] = sortie 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), "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), }