classement, ajoute une suite de tests pytest - calcul.py: matin_debut/fin, aprem_debut/fin et pause_dejeuner_fin sont paramétrables via config.json > plages (moteur de calcul de l'heure de sortie optimale inclus, pas seulement l'affichage). Défauts identiques à l'ancien comportement, validé par la suite de tests. - stats.py: les seuils de conformité utilisent la même config. - main.py/templates: warn de saisie et suffixes de cible dynamiques. - Rappels ntfy: serveur, topic et plage horaire (rappel_debut_h/fin_h) configurables par utilisateur depuis /settings, plus en dur dans le code. - Page classement supprimée (pas de compétition entre utilisateurs). - Nouvelle suite pytest (app/tests/), à lancer via `docker compose run --rm pointeuse pytest -v`.
186 lines
8.2 KiB
Python
186 lines
8.2 KiB
Python
"""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,
|
|
}
|