Files
pointeuse-optimisator/app/calcul.py
Antoine 761a86986a Projette les cibles en temps réel et corrige le fuseau horaire
Calcule les heures travaillées et cibles d'entrée/sortie même quand une
demi-journée est en cours (pas encore de pointage de sortie), au lieu
d'attendre la fin de journée. Ajoute un endpoint /refresh pour
rafraîchir l'affichage sans repointer. Désambiguïse les libellés de
semaine sur plusieurs années et force TZ=Europe/Paris dans le conteneur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 14:12:03 +02:00

172 lines
7.3 KiB
Python

"""Business logic: compute worked hours and expected hours per day/week."""
from datetime import datetime, timedelta
APREM_DEBUT_MIN = 13 * 60 + 30 # 13:30 — pause déjeuner de base 12h00-13h30
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) -> dict:
"""Compute totals for a list of pointages (one week)."""
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 > 3 * 60: # plus que le créneau 9h-12h obligatoire
entree_min = 12 * 60 - needed_today_mat
if entree_min >= 6 * 60: # avant 9h obligatoire, pas avant 6h
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'à midi
# (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_noon = max(0, 12 * 60 - entree_min)
worked_morning = max(elapsed, projected_to_noon)
needed_aprem = max(0, needed_today - worked_morning)
aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else APREM_DEBUT_MIN
sortie_min = max(aprem_start + needed_aprem, 17 * 60)
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 > (17 * 60 - APREM_DEBUT_MIN):
entree_min = 17 * 60 - needed_aprem
if 12 * 60 < entree_min < APREM_DEBUT_MIN: # avant l'heure de reprise par défaut, après le déjeuner
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,
}