"""Business logic: compute worked hours and expected hours per day/week.""" from datetime import 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 } 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"]) matin_window = matin_fin_min - matin_debut_min 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 # Sortie cible: optimal aprem exit so the week totals exactly the target hours today = datetime.today().strftime("%Y-%m-%d") now_min = datetime.today().hour * 60 + datetime.today().minute worked_before = 0 for i, jour in enumerate(jours): cg = jour["conge"] # Jour futur pas encore entamé : pas de cible, le rattrapage n'est pas figé sur ce jour précis is_future_untouched = jour["date"] > today and not jour.get("matin_entree") and not jour.get("aprem_entree") if is_future_untouched: jour["sortie_cible"] = None jour["entree_cible"] = None worked_before += jour["travaille_min"] continue if jour["du_min"] == 0 or "aprem" in cg or "jour" in cg: jour["sortie_cible"] = None jour["entree_cible"] = None if "aprem" in cg and "matin" not in cg and jour["du_min"] > 0: du_remaining_mat = sum(j["du_min"] for j in jours[i + 1:]) if du_remaining_mat == 0 and not jour.get("matin_entree"): needed_today_mat = max(0, total_du - worked_before - du_remaining_mat) if needed_today_mat > matin_window: # plus que le créneau matin obligatoire entree_min = matin_fin_min - needed_today_mat floor_min = matin_debut_min - matin_window # pas avant ce plancher symétrique if entree_min >= floor_min: jour["entree_cible"] = f"{entree_min // 60:02d}:{entree_min % 60:02d}" worked_before += jour["travaille_min"] continue du_remaining = sum(j["du_min"] for j in jours[i + 1:]) needed_today = max(0, total_du - worked_before - du_remaining) worked_morning = 0 if jour.get("matin_entree") and jour.get("matin_sortie"): worked_morning = max(0, hhmm_to_minutes(jour["matin_sortie"]) - hhmm_to_minutes(jour["matin_entree"])) elif jour.get("matin_entree") and jour["date"] == today: # Pas de sortie pointée le matin : projeter une matinée normale jusqu'à la fin de la plage # (sinon la cible dumperait tout le quota du jour sur l'après-midi dès l'arrivée). entree_min = hhmm_to_minutes(jour["matin_entree"]) elapsed = max(0, now_min - entree_min) projected_to_fin_matin = max(0, matin_fin_min - entree_min) worked_morning = max(elapsed, projected_to_fin_matin) needed_aprem = max(0, needed_today - worked_morning) aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min sortie_min = max(aprem_start + needed_aprem, aprem_fin_min) if sortie_min > 23 * 60 + 59: jour["sortie_cible"] = None # deficit too large to recover in one afternoon else: jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" entree_cible = None if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (aprem_fin_min - pause_dejeuner_fin_min): entree_min = aprem_fin_min - needed_aprem if matin_fin_min < entree_min < pause_dejeuner_fin_min: # après le déjeuner, avant la reprise par défaut entree_cible = f"{entree_min // 60:02d}:{entree_min % 60:02d}" jour["entree_cible"] = entree_cible worked_before += jour["travaille_min"] 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, }