diff --git a/README.md b/README.md index b4a5e4d..c8fb04c 100644 --- a/README.md +++ b/README.md @@ -182,17 +182,15 @@ demi-journées restantes. ## Astreinte & repos de 11h Le bouton **AS** marque un jour d'astreinte. Ce jour et son lendemain (la nuit qui -suit), une ligne de saisie supplémentaire apparaît sous l'après-midi : +suit), une liste d'événements apparaît sous l'après-midi : autant de lignes que +nécessaire (**+ intervention**), chacune avec un **début**, une **fin** et une +description libre. Une fin antérieure au début (fin < début) désigne une +intervention à cheval sur minuit. -- **Fin activité** : l'heure de fin de la **dernière activité** hors horaires de - travail. Elle peut tomber le matin (intervention ayant dépassé minuit) : saisie - sur le jour d'astreinte, une fin matinale (ex: 06:00) désigne la fin de sa nuit ; - saisie sur le lendemain, une fin matinale désigne la nuit de la veille. - -L'app en déduit l'heure de retour au bureau : **fin + 11h de repos consécutif** -(exigées par période de 24h), affichée sous le champ (« retour ≥ 13:00 »). Fin -23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 ; fin 06:00 → retour -17:00. Un repos qui se termine avant l'ouverture ne contraint rien. +L'app en déduit l'heure de retour au bureau : **fin du dernier événement + 11h de +repos consécutif** (exigées par période de 24h), affichée sous la liste (« retour +≥ 13:00 »). Fin 23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le +jour même. Un repos qui se termine avant l'ouverture ne contraint rien. À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le décompte de la semaine : @@ -204,9 +202,25 @@ décompte de la semaine : décalée d'autant, sans avertissement de retard) ; - une demi-journée entièrement couverte par le repos est considérée comme remplie. -La fin d'activité ne s'applique que les jours qualifiés (astreinte ou lendemain) : -partout ailleurs elle est ignorée. Les colonnes **Astreinte** (fin saisie) et -**Retour** (calculé) apparaissent dans les exports `.ods`. +Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles +sont décomptées à part, par pas de 15 minutes (toute tranche entamée compte en +totalité), dans des compteurs pondérés selon le jour et l'heure. Sous le bandeau +de la semaine s'affiche le détail par événement, les totaux par compteur et le +total pondéré. + +Par défaut : **Nuit** (20:00→08:00, tous types de jours) ×1,5 ; **Journée** (semaine +Lun-Ven, 08:00→20:00) ×1 ; **Samedi** (08:00→20:00) ×1,5 ; **Dimanche / férié** +(08:00→20:00) ×2. Les jours fériés français (fixes + Pâques, Ascension, Pentecôte) +sont calculés automatiquement. + +Ces règles sont ajustables dans `data/config.json`, clé `astreinte_facteurs` : +chaque règle porte un `nom`, les `jours` concernés (`semaine`, `sam`, `dim`, +`ferie`), un `debut`, une `fin` et un `facteur`. Une tranche qui ne correspond à +aucune règle est rangée dans « Autre » avec un facteur ×1. + +Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) : +partout ailleurs ils sont ignorés. Les colonnes **Astreinte** (brut), **Astreinte +pondérée** et **Retour** (calculé) apparaissent dans les exports `.ods`. --- diff --git a/app/calcul.py b/app/calcul.py index 7ffd541..48d1815 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -1,5 +1,5 @@ """Business logic: compute worked hours and expected hours per day/week.""" -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta DEFAULT_PLAGES = { "matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin @@ -12,6 +12,19 @@ DEFAULT_PLAGES = { } REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau +PAS_ASTREINTE_MIN = 15 # pas du décompte astreinte : toute tranche entamée compte en totalité + +# Règles de comptage des heures d'astreinte : chaque tranche de 15 min est +# attribuée à la première règle dont le type de jour et la fenêtre horaire la +# couvrent, puis pondérée par son facteur. "jours" parmi : semaine, sam, dim, ferie. +DEFAULT_FACTEURS_ASTREINTE = [ + {"nom": "Nuit", "jours": ["semaine", "sam", "dim", "ferie"], "debut": "20:00", "fin": "08:00", "facteur": 1.5}, + {"nom": "Journée", "jours": ["semaine"], "debut": "08:00", "fin": "20:00", "facteur": 1.0}, + {"nom": "Samedi", "jours": ["sam"], "debut": "08:00", "fin": "20:00", "facteur": 1.5}, + {"nom": "Dimanche / férié", "jours": ["dim", "ferie"], "debut": "08:00", "fin": "20:00", "facteur": 2.0}, +] + +_EPOCH_DATE = date(2000, 1, 1) def hhmm_to_minutes(val: str | None) -> int: @@ -73,48 +86,192 @@ def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, ap return max(0, m - matin_excl_min), max(0, a - aprem_excl_min) +# ── Jours fériés français (fixes + mobiles) ─────────────────────────────────── + +def _paques(annee: int) -> date: + """Dimanche de Pâques (algorithme de Meeus/Jones/Butcher).""" + a = annee % 19 + b, c = divmod(annee, 100) + d, e = divmod(b, 4) + f = (b + 8) // 25 + g = (b - f + 1) // 3 + h = (19 * a + b - d - g + 15) % 30 + i, k = divmod(c, 4) + l = (32 + 2 * e + 2 * i - h - k) % 7 + m = (a + 11 * h + 22 * l) // 451 + mois, jour = divmod(h + l - 7 * m + 114, 31) + return date(annee, mois, jour + 1) + + +def _jours_feries(annee: int) -> set[date]: + p = _paques(annee) + return { + date(annee, 1, 1), date(annee, 5, 1), date(annee, 5, 8), + date(annee, 7, 14), date(annee, 8, 15), date(annee, 11, 1), + date(annee, 11, 11), date(annee, 12, 25), + p + timedelta(days=1), # lundi de Pâques + p + timedelta(days=39), # Ascension + p + timedelta(days=50), # lundi de Pentecôte + } + + +def _categorie_jour(d: date, feries: set[date]) -> str: + if d in feries: + return "ferie" + return {5: "sam", 6: "dim"}.get(d.weekday(), "semaine") + + +def _dans_fenetre(minute: int, debut_min: int, fin_min: int) -> bool: + if debut_min <= fin_min: + return debut_min <= minute < fin_min + return minute >= debut_min or minute < fin_min # fenêtre à cheval sur minuit + + +def _facteur_txt(facteur: float) -> str: + return f"{facteur:.2f}".rstrip("0").rstrip(".").replace(".", ",") + + +def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]: + return [ + { + "nom": str(r.get("nom") or "?"), + "jours": set(r.get("jours") or ()), + "debut": hhmm_to_minutes(r.get("debut")), + "fin": hhmm_to_minutes(r.get("fin")), + "facteur": float(r.get("facteur") or 1), + } + for r in (facteurs if facteurs else DEFAULT_FACTEURS_ASTREINTE) + ] + + +def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None) -> dict: + """Décompte des heures réalisées en astreinte pour un jour, par pas de 15 min : + toute tranche entamée est comptée en totalité. Chaque tranche est attribuée à la + première règle (type de jour × fenêtre horaire) qui la couvre, puis pondérée par + son facteur. Les événements vivent sur la timeline réelle : fin < début = + intervention à cheval sur minuit, dont la partie après minuit compte sur le jour + suivant. Les tranches couvertes par plusieurs événements ne sont comptées qu'une + fois dans les totaux du jour. + + Renvoie {tranches, total_min, total_h, total_pondere_min, total_pondere_h, + events: [détail par événement], categories: [compteurs par règle]}. + """ + regles = _regles_astreinte(facteurs) + jour0 = datetime.strptime(date_str, "%Y-%m-%d").date() + feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1) + + def _regle_du_quart(qm: int) -> dict | None: + cat_j = _categorie_jour(_EPOCH_DATE + timedelta(days=qm // 1440), feries) + minute = qm % 1440 + return next((r for r in regles if cat_j in r["jours"] and _dans_fenetre(minute, r["debut"], r["fin"])), None) + + # Attribution de chaque tranche (minute absolue alignée sur le pas) à sa règle ; + # la première affectation gagne si des événements se chevauchent. + quart_regles: dict[int, dict | None] = {} + events_out = [] + for e in events: + deb, fin = e.get("debut"), e.get("fin") + if not deb or not fin: + continue + deb_min, fin_min = hhmm_to_minutes(deb), hhmm_to_minutes(fin) + duree = fin_min - deb_min if fin_min >= deb_min else 24 * 60 - deb_min + fin_min + a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min + quarts = list(range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN, + a0 + duree, PAS_ASTREINTE_MIN)) + for qm in quarts: + quart_regles.setdefault(qm, _regle_du_quart(qm)) + ev_cats: dict[str, dict] = {} + for qm in quarts: + regle = quart_regles.get(qm) + nom = regle["nom"] if regle else "Autre" + facteur = regle["facteur"] if regle else 1.0 + c = ev_cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "tranches": 0}) + c["tranches"] += 1 + ev_min = len(quarts) * PAS_ASTREINTE_MIN + ev_details = [ + {**c, "min": c["tranches"] * PAS_ASTREINTE_MIN, + "min_h": minutes_to_hhmm(c["tranches"] * PAS_ASTREINTE_MIN), + "pondere_min": int(c["tranches"] * PAS_ASTREINTE_MIN * c["facteur"] + 0.5)} + for c in ev_cats.values() + ] + ev_pondere = sum(d["pondere_min"] for d in ev_details) + events_out.append({ + "debut": deb, "fin": fin, "desc": e.get("desc") or "", + "tranches": len(quarts), "min": ev_min, "min_h": minutes_to_hhmm(ev_min), + "pondere_min": ev_pondere, "pondere_h": minutes_to_hhmm(ev_pondere), + "details": ev_details, + }) + + cats: dict[str, dict] = {} + for qm, regle in quart_regles.items(): + nom = regle["nom"] if regle else "Autre" + facteur = regle["facteur"] if regle else 1.0 + c = cats.setdefault(nom, {"nom": nom, "facteur": facteur, "facteur_txt": _facteur_txt(facteur), "tranches": 0}) + c["tranches"] += 1 + categories = [] + for nom, c in cats.items(): + cmin = c["tranches"] * PAS_ASTREINTE_MIN + categories.append({**c, "min": cmin, "min_h": minutes_to_hhmm(cmin), + "pondere_min": int(cmin * c["facteur"] + 0.5), + "pondere_h": minutes_to_hhmm(int(cmin * c["facteur"] + 0.5))}) + ordre = [r["nom"] for r in regles] + ["Autre"] + categories.sort(key=lambda c: ordre.index(c["nom"]) if c["nom"] in ordre else len(ordre)) + + total_min = len(quart_regles) * PAS_ASTREINTE_MIN + total_pondere = sum(c["pondere_min"] for c in categories) + return { + "tranches": len(quart_regles), + "total_min": total_min, + "total_h": minutes_to_hhmm(total_min), + "total_pondere_min": total_pondere, + "total_pondere_h": minutes_to_hhmm(total_pondere), + "events": events_out, + "categories": categories, + } + + def retour_apres_repos(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None: """Heure de retour au bureau après les 11h de repos consécutif qui suivent la dernière activité d'astreinte. - Seule l'heure de FIN de cette activité est saisie, sur le jour d'astreinte ou - son lendemain. La fin peut tomber le matin (l'intervention a dépassé minuit) : - saisie sur le jour d'astreinte, une fin matinale désigne la fin de SA nuit ; - saisie sur le lendemain, une fin matinale désigne la fin de la nuit de la - veille. Le retour s'applique au jour où se termine le repos : fin 23:30 → - retour 10:30 le lendemain ; fin 06:00 → retour 17:00. Renvoie des minutes - depuis minuit, ou None si rien ne contraint le retour au-delà de l'ouverture.""" + Chaque activité d'astreinte est un événement {debut, fin} saisi sur le jour de + son début (les événements remplacent l'ancienne fin unique). fin < debut + désigne une intervention à cheval sur minuit, dont la fin (heure du lendemain) + termine l'activité. Le retour s'applique au jour où se termine le repos : fin + 23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le jour même. + Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour + au-delà de l'ouverture.""" astreintes = set(astreintes or []) - matin_debut = hhmm_to_minutes(plages["matin_debut"]) veille = _veille(date_str) cands = [] - def _fin_activite(date_op: str) -> int | None: + def _fins(date_op: str): row = p_by_date.get(date_op) or {} - fin = row.get("astreinte_fin") - return hhmm_to_minutes(fin) if fin else None + for e in row.get("astreinte_events") or []: + deb, fin = e.get("debut"), e.get("fin") + if deb and fin: + yield hhmm_to_minutes(deb), hhmm_to_minutes(fin) - # Fin saisie sur la veille : fin de soirée → repos terminé aujourd'hui ; fin - # matinale sur un jour d'astreinte → la nuit a dépassé minuit, le repos se - # termine aussi aujourd'hui. - if jour_astreinte_qualifie(veille, astreintes): - fin = _fin_activite(veille) - if fin is not None: - if fin + REPOS_QUOTIDIEN_MIN >= 24 * 60: - cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60) - elif veille in astreintes and fin < matin_debut: + # Événements du jour : activité terminée aujourd'hui, le repos s'achève donc + # aujourd'hui (une fin matinale est la fin d'une intervention nocturne du jour). + if jour_astreinte_qualifie(date_str, astreintes): + for deb, fin in _fins(date_str): + if fin >= deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60: + cands.append(fin + REPOS_QUOTIDIEN_MIN) + # Événements de la veille : fin de soirée → le repos se termine aujourd'hui ; + # fin après minuit (fin < deb) → l'activité s'est achevée aujourd'hui à fin, + # le repos s'achève donc aujourd'hui. + if jour_astreinte_qualifie(veille, astreintes): + for deb, fin in _fins(veille): + if fin >= deb and fin + REPOS_QUOTIDIEN_MIN >= 24 * 60: + cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60) + if fin < deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60: cands.append(fin + REPOS_QUOTIDIEN_MIN) - # Fin matinale saisie sur le jour même (lendemain d'astreinte) : la nuit de - # la veille s'est terminée après minuit. - if veille in astreintes: - fin = _fin_activite(date_str) - if fin is not None and fin < matin_debut: - cands.append(fin + REPOS_QUOTIDIEN_MIN) if not cands: return None best = max(cands) # Un repos qui se termine avant l'ouverture ne contraint rien - return best if best > matin_debut else None + return best if best > hhmm_to_minutes(plages["matin_debut"]) else None def heures_travaillees(p: dict, retour_min: int | None = None) -> int: @@ -178,7 +335,7 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int, return max(0, du) -def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None) -> dict: +def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None) -> dict: """Compute totals for a list of pointages (one week).""" plages = {**DEFAULT_PLAGES, **(plages or {})} astreintes = set(astreintes or []) @@ -193,12 +350,21 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int total_du = 0 jours = [] p_by_date = {p["date"]: p for p in pointages} + # Cumuls astreinte de la semaine, suivis à part : les heures astreinte ne + # comptent ni dans travaille ni dans du (compteurs séparés pondérés). + as_jours = [] + as_total_min = 0 + as_total_pondere_min = 0 + as_cats: dict[str, dict] = {} for p in pointages: conges_date = {c["type"] for c in conges if c["date"] == p["date"]} # Retour au bureau après 11h de repos, calculé depuis l'heure de fin de la # dernière activité d'astreinte (saisie sur le jour ou la veille qualifiés) retour_min = retour_apres_repos(p["date"], p_by_date, plages, astreintes) + # Décompte astreinte du jour : événements multiples, pas de 15 min entamées + events = p.get("astreinte_events") or [] + dec = decompte_astreinte(p["date"], events, facteurs) if (jour_astreinte_qualifie(p["date"], astreintes) and events) else None matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages) travaille = heures_travaillees(p, retour_min) du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min) @@ -223,9 +389,37 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "aprem_nominal_min": aprem_nom, "astreinte": p["date"] in astreintes, "astreinte_qualifiee": jour_astreinte_qualifie(p["date"], astreintes), + "astreinte_min": dec["total_min"] if dec else 0, + "astreinte_h": dec["total_h"] if dec else "", + "astreinte_pondere_min": dec["total_pondere_min"] if dec else 0, "retour_min": retour_min, "retour": minutes_to_time(retour_min) if retour_min is not None else None, }) + if dec and dec["total_min"] > 0: + as_jours.append({"date": p["date"], "events": dec["events"]}) + as_total_min += dec["total_min"] + as_total_pondere_min += dec["total_pondere_min"] + for c in dec["categories"]: + fc = as_cats.setdefault(c["nom"], {"nom": c["nom"], "facteur": c["facteur"], "tranches": 0, "min": 0, "pondere_min": 0}) + fc["tranches"] += c["tranches"] + fc["min"] += c["min"] + fc["pondere_min"] += c["pondere_min"] + + # Catégories astreinte fusionnées sur la semaine, dans l'ordre des règles. + ordre_as = [r["nom"] for r in _regles_astreinte(facteurs)] + ["Autre"] + as_categories = [] + for nom in sorted(as_cats, key=lambda n: ordre_as.index(n) if n in ordre_as else len(ordre_as)): + c = as_cats[nom] + as_categories.append({ + "nom": nom, + "facteur": c["facteur"], + "facteur_txt": _facteur_txt(c["facteur"]), + "tranches": c["tranches"], + "min": c["min"], + "min_h": minutes_to_hhmm(c["min"]), + "pondere_min": c["pondere_min"], + "pondere_h": minutes_to_hhmm(c["pondere_min"]), + }) # Mark each day as complete (all non-congé slots filled, or day is a congé/weekend). # Une demi-journée entièrement couverte par le repos de 11h (retour après sa @@ -403,4 +597,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "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), + "astreinte": { + "actif": as_total_min > 0, + "total_min": as_total_min, + "total_h": minutes_to_hhmm(as_total_min), + "total_pondere_min": as_total_pondere_min, + "total_pondere_h": minutes_to_hhmm(as_total_pondere_min), + "categories": as_categories, + "jours": as_jours, + }, } diff --git a/app/export.py b/app/export.py index b812888..849dc24 100644 --- a/app/export.py +++ b/app/export.py @@ -16,7 +16,7 @@ from models import ( JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"] HEADERS = [ "Date", "Jour", "Matin entrée", "Matin sortie", "Aprem entrée", "Aprem sortie", - "Travaillé", "Dû", "Δ jour", "Congé", "Astreinte", "Retour", + "Travaillé", "Dû", "Δ jour", "Congé", "Astreinte", "Astreinte pondérée", "Retour", ] @@ -61,8 +61,8 @@ def _congé_label(cg: set) -> str: def _astreinte_label(jour: dict) -> str: - if jour.get("astreinte_fin"): - return f"fin {jour['astreinte_fin']}" + if jour.get("astreinte_min"): + return jour["astreinte_h"] return "Astreinte" if jour.get("astreinte") else "" @@ -76,18 +76,20 @@ def _jour_row(jour: dict, nom: str) -> TableRow: ("+" if jour["delta_min"] > 0 else "") + jour["delta"], _congé_label(jour["conge"]), _astreinte_label(jour), + minutes_to_hhmm(jour["astreinte_pondere_min"]) if jour["astreinte_pondere_min"] > 0 else "", jour.get("retour"), ): row.addElement(_cell(v)) return row -def _totals_row(bold: Style, travaille_min: int, du_min: int) -> TableRow: +def _totals_row(bold: Style, travaille_min: int, du_min: int, + as_min: int = 0, as_pondere_min: int = 0) -> TableRow: row = TableRow() for v in ("Total", "", "", "", "", "", minutes_to_hhmm(travaille_min), minutes_to_hhmm(du_min), ("+" if travaille_min - du_min > 0 else "") + minutes_to_hhmm(travaille_min - du_min), - "", "", ""): + "", minutes_to_hhmm(as_min), minutes_to_hhmm(as_pondere_min), ""): row.addElement(_cell(v, bold)) return row @@ -121,7 +123,9 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes: for jour, nom in zip(result["jours"], JOURS_FR): table.addElement(_jour_row(jour, nom)) table.addElement(_totals_row(bold, sum(j["travaille_min"] for j in result["jours"]), - sum(j["du_min"] for j in result["jours"]))) + sum(j["du_min"] for j in result["jours"]), + sum(j["astreinte_min"] for j in result["jours"]), + sum(j["astreinte_pondere_min"] for j in result["jours"]))) doc.spreadsheet.addElement(table) buf = io.BytesIO() @@ -183,7 +187,9 @@ def export_annee_ods(year: int, user_id: str) -> bytes: table.addElement(_header_row(bold)) for jour, nom in zip(result["jours"], JOURS_FR): table.addElement(_jour_row(jour, nom)) - table.addElement(_totals_row(bold, travaille_min, du_min)) + table.addElement(_totals_row(bold, travaille_min, du_min, + sum(j["astreinte_min"] for j in result["jours"]), + sum(j["astreinte_pondere_min"] for j in result["jours"]))) doc.spreadsheet.addElement(table) buf = io.BytesIO() diff --git a/app/main.py b/app/main.py index cf83401..8c0acf7 100644 --- a/app/main.py +++ b/app/main.py @@ -27,11 +27,12 @@ from models import ( load_auth, save_auth, find_user_by_reset_token, load_plages, save_plages, heures_jour_min, append_log, load_logs, load_or_create_secret, + load_facteurs_astreinte, ) from calcul import ( compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues, heures_travaillees, jour_astreinte_qualifie, exclusion_retour, - nominaux_du_jour, retour_apres_repos, + nominaux_du_jour, retour_apres_repos, decompte_astreinte, ) from stats import compute_all_stats from export import export_semaine_ods, export_annee_ods @@ -307,7 +308,7 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id): "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id)) + result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id), load_facteurs_astreinte()) result["heures_jour"] = minutes_to_hhmm(h_jour) return {"result": result} @@ -331,6 +332,11 @@ def _build_jour_ctx(date, conges, h_jour, user_id): du_full = du + matin_ex + aprem_ex matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex) delta = trav - du + # Décompte astreinte du jour (compteur séparé, hors heures de semaine) : + # seulement si des événements sont stockés et que le jour est qualifié. + events = p.get("astreinte_events") or [] + dec = (decompte_astreinte(date, events, load_facteurs_astreinte()) + if events and jour_astreinte_qualifie(date, astreintes) else None) return { **p, "travaille_min": trav, "du_min": du, "delta_min": delta, @@ -345,6 +351,8 @@ def _build_jour_ctx(date, conges, h_jour, user_id): "astreinte_qualifiee": jour_astreinte_qualifie(date, astreintes), "retour_min": retour_min, "retour": minutes_to_time(retour_min) if retour_min is not None else None, + "astreinte_min": dec["total_min"] if dec else 0, + "astreinte_h": dec["total_h"] if dec else "", } @@ -443,22 +451,42 @@ def _btns_html(date, jour): ) +def _as_event_html(date, ev): + """Une ligne d'intervention d'astreinte : début → fin, description, suppression.""" + return ( + f'
' + f'' + f'→' + f'' + f'' + f'' + f'
' + ) + + def _as_block_html(date, jour): - """Bloc astreinte d'une ligne : heure de fin de la dernière activité (hors - horaires), qui déclenche les 11h de repos et décale d'autant l'heure de - retour. Rendu sur les jours qualifiés (astreinte ou lendemain) ; div vide - ailleurs, cible du swap.""" + """Bloc astreinte d'une ligne : événements d'intervention (début/fin, nombre + libre, description), qui déclenchent les 11h de repos et décalent l'heure de + retour, plus le décompte du jour (compteur séparé, hors heures de semaine). + Rendu sur les jours qualifiés (astreinte ou lendemain) ; div vide ailleurs.""" if not jour.get("astreinte_qualifiee"): return f'
' + events = jour.get("astreinte_events") or [] + rows = "".join(_as_event_html(date, ev) for ev in events) or _as_event_html(date, {}) hint = "" if jour.get("retour_min") is not None: hint = (f'' f'retour ≥ {_e(jour["retour"])}') + total = "" + if jour.get("astreinte_min"): + total = (f'' + f'astreinte : {_e(jour["astreinte_h"])}') return ( f'
' - f'Fin activité' - f'' + f'
{rows}
' + f'' f'{hint}' + f'{total}' f'
' ) @@ -692,7 +720,7 @@ def semaine(request: Request, year: int, week: int): "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour, plages, load_astreintes(user_id)) + result = compute_week(pointages, conges, h_jour, plages, load_astreintes(user_id), load_facteurs_astreinte()) prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1) next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1) @@ -718,7 +746,8 @@ def save_pointage( request: Request, date: str, matin_entree: str = Form(""), matin_sortie: str = Form(""), aprem_entree: str = Form(""), aprem_sortie: str = Form(""), - astreinte_fin: str = Form(""), + astreinte_debut: list[str] = Form([]), astreinte_fin: list[str] = Form([]), + astreinte_desc: list[str] = Form([]), ): user_id = _get_user(request) if not user_id: @@ -734,18 +763,23 @@ def save_pointage( iso = d.isocalendar() raw = load_week_pointages(iso.year, iso.week, user_id) p_by_date = {p["date"]: p for p in raw} - # La fin d'activité n'est affichée (donc postée) que les jours qualifiés - # (astreinte ou lendemain) ; ailleurs on conserve la valeur déjà saisie au - # lieu de l'écraser à vide. + # Les événements d'astreinte ne sont affichés (donc postés) que les jours + # qualifiés (astreinte ou lendemain) ; ailleurs on conserve ceux déjà saisis + # au lieu de les écraser à vide. Les paires début/fin invalides sont ignorées. if jour_astreinte_qualifie(date, set(load_astreintes(user_id))): - champs_astreinte = {"astreinte_fin": clean(astreinte_fin)} + events = [] + for deb, fin, desc in zip(astreinte_debut, astreinte_fin, astreinte_desc): + deb, fin = clean(deb), clean(fin) + if deb and fin: + events.append({"debut": deb, "fin": fin, + "desc": (desc or "").strip()[:200] or None}) else: - champs_astreinte = {"astreinte_fin": p_by_date.get(date, {}).get("astreinte_fin")} + events = p_by_date.get(date, {}).get("astreinte_events") p_by_date[date] = { "date": date, "matin_entree": clean(matin_entree), "matin_sortie": clean(matin_sortie), "aprem_entree": clean(aprem_entree), "aprem_sortie": clean(aprem_sortie), - **champs_astreinte, + "astreinte_events": events, } save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]), user_id) conges = load_conges(user_id) diff --git a/app/models.py b/app/models.py index d8b3f85..7264814 100644 --- a/app/models.py +++ b/app/models.py @@ -4,7 +4,7 @@ import secrets from datetime import datetime from pathlib import Path -from calcul import DEFAULT_PLAGES +from calcul import DEFAULT_PLAGES, DEFAULT_FACTEURS_ASTREINTE DATA_DIR = Path("/data") CONFIG_FILE = DATA_DIR / "config.json" @@ -24,6 +24,7 @@ DEFAULT_CONFIG = { "use_tls": True, }, "plages": dict(DEFAULT_PLAGES), + "astreinte_facteurs": list(DEFAULT_FACTEURS_ASTREINTE), } # ── User path helpers ────────────────────────────────────────────────────────── @@ -180,6 +181,15 @@ def toggle_astreinte(date: str, user_id: str = ""): save_astreintes(dates, user_id) +def load_facteurs_astreinte() -> list[dict]: + """Règles globales de comptage des heures d'astreinte (type de jour × fenêtre + horaire × facteur) ; défauts cohérents si absentes ou invalides dans la config.""" + facteurs = load_config().get("astreinte_facteurs") + if facteurs and isinstance(facteurs, list) and all(isinstance(f, dict) for f in facteurs): + return facteurs + return DEFAULT_FACTEURS_ASTREINTE + + # ── Auth (email + password) ──────────────────────────────────────────────────── def _auth_file(user_id: str) -> Path: diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index de03472..7eb2e37 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -43,11 +43,37 @@ {% if j.astreinte_qualifiee %}
- Fin activité - +
+ {% for ev in j.astreinte_events or [] %} +
+ + → + + + +
+ {% else %} +
+ + → + + + +
+ {% endfor %} +
+ {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} + {% if j.astreinte_min %}astreinte : {{ j.astreinte_h }}{% endif %}
{% else %}
diff --git a/app/templates/_soldes.html b/app/templates/_soldes.html index 9eee529..ba191cf 100644 --- a/app/templates/_soldes.html +++ b/app/templates/_soldes.html @@ -43,4 +43,27 @@ complétez ou corrigez les saisies, ou rattrapez sur une autre semaine. {% endif %} + {% if result.astreinte and result.astreinte.actif %} +
+
Astreinte — tranches de 15 min entamées, comptées entières (compteur séparé, hors heures de semaine)
+ {% for j in result.astreinte.jours %} +
+ {{ j.date[8:] }}/{{ j.date[5:7] }} + {% for ev in j.events %} +
+ {{ ev.debut }} → {{ ev.fin }} + {% if ev.desc %}{{ ev.desc }}{% endif %} + {{ ev.tranches }} tr. = {{ ev.min_h }} + {% for d in ev.details %}{{ d.nom }} ×{{ d.facteur_txt }}{% if not loop.last %} + {% endif %}{% endfor %} + pondéré → {{ ev.pondere_h }} +
+ {% endfor %} +
+ {% endfor %} +
+ {% for c in result.astreinte.categories %}{{ c.nom }} : {{ c.min_h }} ×{{ c.facteur_txt }} = {{ c.pondere_h }}{% endfor %} + total {{ result.astreinte.total_h }} → {{ result.astreinte.total_pondere_h }} pondérées +
+
+ {% endif %} diff --git a/app/templates/base.html b/app/templates/base.html index 0c94e15..d4ea039 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -455,6 +455,89 @@ color: var(--accent); white-space: nowrap; } + .as-events { display: flex; flex-direction: column; gap: 3px; width: 100%; } + .as-event { display: flex; align-items: center; gap: 4px; } + .as-desc { + flex: 1; + min-width: 60px; + border: 1px solid var(--rule); + border-radius: 0; + padding: .25rem .3rem; + font-size: 11px; + font-family: var(--sans); + color: var(--text); + background: transparent; + transition: border-color .15s; + } + .as-desc:hover { border-color: var(--muted); } + .as-desc:focus { outline: none; border-color: var(--accent); background: #fff; } + .as-del { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; height: 18px; + border: 1px solid var(--rule); + background: transparent; + cursor: pointer; + font-family: var(--mono); + font-size: 10px; + color: var(--muted); + flex-shrink: 0; + transition: border-color .15s, color .15s; + } + .as-del:hover { border-color: var(--neg); color: var(--neg); } + .as-add { + border: none; + background: none; + padding: 0; + cursor: pointer; + font-family: var(--mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--accent-2); + white-space: nowrap; + } + .as-add:hover { text-decoration: underline; } + .as-total { + font-family: var(--mono); + font-size: 10px; + color: var(--accent-2); + white-space: nowrap; + } + + /* ── Décompte astreinte (section soldes) ── */ + .as-decompte { + border: 1px solid var(--rule); + background: var(--surface); + margin-top: .6rem; + padding: .55rem .75rem; + font-size: 12px; + } + .as-decompte-title { + font-family: var(--mono); + font-size: 9px; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: .35rem; + } + .as-ev { display: flex; flex-wrap: wrap; gap: .45rem; align-items: baseline; padding: .12rem 0; } + .as-ev-h { font-family: var(--mono); } + .as-ev-desc { color: var(--muted); font-style: italic; } + .as-ev-q { font-family: var(--mono); color: var(--text); } + .as-ev-cat, .as-cat { color: var(--accent); font-family: var(--mono); font-size: 11px; } + .as-ev-pond { font-family: var(--mono); color: var(--accent-2); } + .as-jour-date { font-family: var(--mono); font-weight: 500; margin-right: .4rem; } + .as-totaux { + border-top: 1px dashed var(--rule); + margin-top: .4rem; + padding-top: .4rem; + display: flex; + flex-wrap: wrap; + gap: .75rem; + } + .as-grand { font-family: var(--mono); font-weight: 500; } /* ── Tableau hebdo en cartes empilées sous 700px (évite le scroll horizontal) ── */ @media (max-width: 700px) { @@ -519,6 +602,7 @@ .pointage-table .btn-cg.btn-jour { width: 32px; height: 32px; } .as-block { padding: .4rem .75rem .55rem; } .as-block .time-input { width: 92px; } + .as-desc { font-size: 13px; padding: .4rem .4rem; } } /* ── Historique ── */ diff --git a/app/templates/semaine.html b/app/templates/semaine.html index 253b632..95c02c0 100644 --- a/app/templates/semaine.html +++ b/app/templates/semaine.html @@ -366,4 +366,21 @@ document.addEventListener('input', function(e) { } }); + + {% endblock %} diff --git a/app/tests/test_astreinte.py b/app/tests/test_astreinte.py index da43c6b..c83fca6 100644 --- a/app/tests/test_astreinte.py +++ b/app/tests/test_astreinte.py @@ -1,8 +1,8 @@ -"""Astreinte : fin de dernière activité, repos de 11h et heure de retour décalée.""" +"""Astreinte : événements, décompte par tranches de 15 min, repos de 11h et retour décalé.""" from calcul import ( - compute_week, heures_dues, heures_travaillees, hhmm_to_minutes, + compute_week, decompte_astreinte, heures_dues, heures_travaillees, hhmm_to_minutes, jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, - retour_apres_repos, DEFAULT_PLAGES, + retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN, ) import models @@ -11,12 +11,12 @@ PLAGES = dict(DEFAULT_PLAGES) def _pointage(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None, - astreinte_fin=None): + astreinte_events=None): return { "date": date, "matin_entree": matin_entree, "matin_sortie": matin_sortie, "aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie, - "astreinte_fin": astreinte_fin, + "astreinte_events": astreinte_events, } @@ -72,44 +72,151 @@ def test_heures_travaillees_entree_chevauchant_le_retour(): assert heures_travaillees(p, hhmm_to_minutes("10:30")) == 90 -# ── Retour au plus tôt après 11h de repos ───────────────────────────────────── +# ── Retour au plus tôt après 11h de repos (depuis le dernier événement) ─────── -def test_retour_fin_matinee_sur_le_lendemain(): - # fin 02:00 saisie sur le lendemain → retour 13:00 le même jour - p_by_date = {"2026-07-15": _pointage("2026-07-15", astreinte_fin="02:00")} +def test_retour_fin_nocturne_sur_le_lendemain(): + """Event 01:00→02:00 saisi sur le lendemain de l'astreinte → retour 13:00.""" + p_by_date = {"2026-07-15": _pointage("2026-07-15", + astreinte_events=[{"debut": "01:00", "fin": "02:00"}])} + # 02:00 + 11h = 13:00 assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60 -def test_retour_fin_matinale_sur_le_jour_d_astreinte(): - # fin 06:00 saisie sur le jour d'astreinte (la nuit a dépassé minuit) → retour 17:00 le lendemain - p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="06:00")} - assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60 +def test_retour_activite_matinale_sur_le_jour_meme(): + """Event 05:00→06:00 sur le jour d'astreinte : l'activité s'est réellement + terminée à 06:00 ce jour-là → repos jusqu'à 17:00 le même jour.""" + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "05:00", "fin": "06:00"}])} + assert retour_apres_repos("2026-07-14", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60 def test_retour_fin_du_soir_sur_le_jour_d_astreinte(): - # fin 23:30 sur le jour d'astreinte → repos terminé 10:30 le lendemain - p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30")} + """Event 21:00→23:30 sur le jour d'astreinte → repos terminé 10:30 le lendemain.""" + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "21:00", "fin": "23:30"}])} + # 23:30 + 11h = 10:30 le lendemain assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 30 +def test_retour_event_a_cheval_sur_minuit_saisi_la_veille(): + """Event 23:00→01:00 (fin < début) saisi la veille : fin réelle 01:00 le jour + même → 01:00 + 11h = 12:00.""" + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "23:00", "fin": "01:00"}])} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60 + + +def test_retour_nuit_d_astreinte_finissant_au_matin(): + """Event 23:00→06:00 (à cheval sur minuit) sur le jour d'astreinte : fin réelle + 06:00 le lendemain → retour 17:00 le lendemain.""" + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "23:00", "fin": "06:00"}])} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60 + + def test_retour_la_plus_tardive_des_deux_fins_gagne(): - # fins saisies sur le jour d'astreinte (23:30) ET le lendemain (02:00) : la plus tardive gagne + """Fins veille 23:30 (retour 10:30) ET jour 02:00 (retour 13:00) : la plus tardive gagne.""" p_by_date = { - "2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30"), - "2026-07-15": _pointage("2026-07-15", astreinte_fin="02:00"), + "2026-07-14": _pointage("2026-07-14", astreinte_events=[{"debut": "21:00", "fin": "23:30"}]), + "2026-07-15": _pointage("2026-07-15", astreinte_events=[{"debut": "01:00", "fin": "02:00"}]), } assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60 +def test_retour_event_sans_astreinte_ignore(): + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "21:00", "fin": "23:30"}])} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, []) is None + + def test_retour_fin_tot_ne_contraint_rien(): - # fin 17:30 → repos terminé 04:30, avant l'ouverture : pas de contrainte - p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="17:30")} + """Event 17:15→17:30 → repos terminé 04:30, avant l'ouverture : pas de contrainte.""" + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "17:15", "fin": "17:30"}])} assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None -def test_retour_fin_sans_astreinte_ignoree(): - p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30")} - assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, []) is None +# ── Décompte astreinte : tranches de 15 min entamées, par règle ─────────────── + +def test_decompte_tranche_entamee_comptee_entiere(): + """22:07→22:20 touche les tranches alignées 22:00 et 22:15 : comptées entières.""" + d = decompte_astreinte("2026-07-13", [{"debut": "22:07", "fin": "22:20"}]) + assert d["tranches"] == 2 # tranches 22:00 + 22:15 entamées + assert d["total_min"] == 2 * PAS_ASTREINTE_MIN # 30 + assert d["total_h"] == "0h30" + assert d["categories"][0]["nom"] == "Nuit" # lundi 22:xx → Nuit ×1,5 + assert d["total_pondere_min"] == 45 # 30 × 1,5 + + +def test_decompte_arrondi_des_bornes(): + """09:50→10:05 touche les tranches 09:45 et 10:00 : 2 tranches en Journée ×1.""" + d = decompte_astreinte("2026-07-13", [{"debut": "09:50", "fin": "10:05"}]) + assert d["tranches"] == 2 # tranches 09:45 + 10:00 + assert d["total_min"] == 30 + assert d["categories"][0]["nom"] == "Journée" # lundi en journée ×1 + assert d["total_pondere_min"] == 30 + + +def test_decompte_evenement_a_cheval_sur_minuit(): + """Mardi 14/07 (férié), 23:00→01:00 : 8 tranches, tout en Nuit ×1,5.""" + d = decompte_astreinte("2026-07-14", [{"debut": "23:00", "fin": "01:00"}]) + assert d["tranches"] == 8 # 23:00→01:00 = 120 min = 8 × 15 + assert d["total_min"] == 120 + assert d["categories"][0]["nom"] == "Nuit" + assert d["total_pondere_min"] == 180 # 120 × 1,5 + + +def test_decompte_jour_ferie_en_journee(): + """14 juillet : la journée compte en « Dimanche / férié » ×2.""" + d = decompte_astreinte("2026-07-14", [{"debut": "10:00", "fin": "12:00"}]) + assert d["total_min"] == 120 # 2h = 8 tranches + assert d["categories"][0]["nom"] == "Dimanche / férié" + assert d["total_pondere_min"] == 240 # 120 × 2 + assert d["total_pondere_h"] == "4h00" + + +def test_decompte_vendredi_vers_samedi_reste_en_nuit(): + """23:00→02:00 le vendredi : 12 tranches, toutes en Nuit (même après minuit).""" + d = decompte_astreinte("2026-07-17", [{"debut": "23:00", "fin": "02:00"}]) + assert d["tranches"] == 12 # 180 min + assert d["total_min"] == 180 + assert [c["nom"] for c in d["categories"]] == ["Nuit"] + assert d["total_pondere_min"] == 270 # 180 × 1,5 + + +def test_decompte_chevauchement_dedouble(): + """10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent + qu'une fois dans le total du jour, chaque event garde son propre détail.""" + d = decompte_astreinte("2026-07-13", [ + {"debut": "10:00", "fin": "10:20"}, + {"debut": "10:10", "fin": "10:30"}, + ]) + assert len(d["events"]) == 2 + assert d["events"][0]["tranches"] == 2 # tranches 10:00 + 10:15 + assert d["events"][0]["min"] == 30 + assert d["events"][1]["tranches"] == 2 # 10:10 aligné sur la tranche 10:00 + assert d["events"][1]["min"] == 30 + assert d["tranches"] == 2 # union dédoublonnée + assert d["total_min"] == 30 + assert d["categories"][0]["nom"] == "Journée" + + +def test_decompte_event_incomplet_ignore(): + """Un événement sans debut ou sans fin est ignoré.""" + d = decompte_astreinte("2026-07-13", [{"debut": "22:00"}, {"fin": "23:00"}, {}]) + assert d["tranches"] == 0 + assert d["total_min"] == 0 + assert d["events"] == [] + assert d["categories"] == [] + + +def test_decompte_regles_personnalisees(): + """Une config custom remplace les règles par défaut (fenêtre à cheval sur minuit).""" + facteurs = [{"nom": "Garde", "jours": ["semaine"], "debut": "18:00", "fin": "09:00", "facteur": 2}] + d = decompte_astreinte("2026-07-13", [{"debut": "20:00", "fin": "21:00"}], facteurs) + assert d["total_min"] == 60 + assert d["categories"][0]["nom"] == "Garde" + assert d["total_pondere_min"] == 120 # ── Nominaux ────────────────────────────────────────────────────────────────── @@ -126,33 +233,65 @@ def test_nominaux_sans_exclusion_reste_la_moitié(): # ── compute_week ────────────────────────────────────────────────────────────── +def test_compute_week_decompte_absent_des_heures_de_semaine(freeze_today): + """Lundi et jeudi complets, mardi astreinte avec event 23:00→01:00, mercredi + vide : les heures d'astreinte n'entrent jamais dans total_travaille/total_du, + seulement dans result["astreinte"].""" + pointages = [ + _pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"), + _pointage("2026-07-14", astreinte_events=[{"debut": "23:00", "fin": "01:00"}]), + _pointage("2026-07-15"), + _pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"), + _pointage("2026-07-17"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) + + # seul le pointé compte : 2 × 468 = 15h36, astreinte exclue + assert result["total_travaille"] == "15h36" + + ast = result["astreinte"] + assert ast["actif"] is True + assert ast["total_min"] == 120 # 23:00→01:00 = 8 tranches + assert ast["total_h"] == "2h00" + assert ast["total_pondere_min"] == 180 # Nuit ×1,5 + assert ast["total_pondere_h"] == "3h00" + assert ast["categories"][0]["nom"] == "Nuit" + assert any(j["date"] == "2026-07-14" for j in ast["jours"]) + + mardi = result["jours"][1] + assert mardi["astreinte_min"] == 120 + assert mardi["astreinte_h"] == "2h00" + assert mardi["astreinte_pondere_min"] == 180 + + def test_compute_week_fin_nocturne_decale_le_decompte_et_les_cibles(freeze_today): - """Mardi astreinte, activité terminée mercredi 02:00 (fin saisie sur le - lendemain) : retour mercredi 13:00, présence du matin retirée de la semaine, - aucune cible matin, après-midi 13:30→18:18 (nominal 288 = 234 + report).""" + """Mardi astreinte, event 01:00→02:00 saisi sur le mercredi (lendemain) : + retour mercredi 13:00, présence du matin retirée de la semaine, aucune cible + matin, après-midi 13:30→18:18 (nominal 288 = 234 + report).""" pointages = [ _pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"), _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"), - _pointage("2026-07-15", astreinte_fin="02:00"), + _pointage("2026-07-15", astreinte_events=[{"debut": "01:00", "fin": "02:00"}]), _pointage("2026-07-16"), _pointage("2026-07-17"), ] result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) mercredi = result["jours"][2] - assert mercredi["du_min"] == 468 - 180 - assert mercredi["retour"] == "13:00" + assert mercredi["du_min"] == 468 - 180 # matin 09:00→12:00 exclu par le retour 13:00 + assert mercredi["retour"] == "13:00" # 02:00 + 11h assert mercredi["cible_matin"] is None assert mercredi["cible_aprem"] == "18:18" - assert result["total_du"] == "36h00" # 39h - 3h de présence retirées + assert result["total_du"] == "36h00" # 39h - 3h de présence retirées -def test_compute_week_fin_matinale_sur_jour_d_astreinte(freeze_today): - """Fin 06:00 saisie directement sur le mardi astreinte : le retour 17:00 - s'applique au mercredi (matin et après-midi exclus jusqu'à 17:00).""" +def test_compute_week_nuit_finissant_au_matin(freeze_today): + """Event 23:00→06:00 (à cheval sur minuit) saisi sur le mardi astreinte : le + retour 17:00 s'applique au mercredi (matin et après-midi exclus jusqu'à 17:00).""" pointages = [ _pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"), - _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54", astreinte_fin="06:00"), + _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54", + astreinte_events=[{"debut": "23:00", "fin": "06:00"}]), _pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"), @@ -167,30 +306,38 @@ def test_compute_week_fin_matinale_sur_jour_d_astreinte(freeze_today): # aprem démarre à 17:00, nominal 108 (54 matin + 54 aprem) → 18:48 assert mercredi["cible_aprem_entree"] == "17:00" assert mercredi["cible_aprem"] == "18:48" + # décompte : 23:00→06:00 = 28 tranches = 7h en Nuit ×1,5 + assert result["jours"][1]["astreinte_min"] == 420 + assert result["astreinte"]["total_pondere_min"] == 630 -def test_compute_week_fin_ignoree_hors_jour_qualifie(freeze_today): - """Une fin d'activité saisie un jour non qualifié (pas d'astreinte la veille) - ne change rien au décompte.""" +def test_compute_week_event_ignore_hors_jour_qualifie(freeze_today): + """Un event saisi un jour non qualifié (pas d'astreinte) ne change rien au + décompte ni au décompte d'astreinte.""" pointages = [ _pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"), - _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54", astreinte_fin="02:00"), + _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54", + astreinte_events=[{"debut": "01:00", "fin": "02:00"}]), _pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"), ] result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, []) - assert result["jours"][1]["du_min"] == HEURES_JOUR_MIN - assert result["jours"][1]["retour_min"] is None + mardi = result["jours"][1] + assert mardi["du_min"] == HEURES_JOUR_MIN + assert mardi["retour_min"] is None + assert mardi["astreinte_min"] == 0 + assert result["astreinte"]["actif"] is False def test_compute_week_arrivee_visee_ne_precede_jamais_le_retour(freeze_today): - """Fin 23:30 mardi → retour mercredi 10:30 : arrivée cible du matin 10:30, - sortie 12:00, et le nominal qui ne tient pas (234-90=144 → 54 manquantes) - bascule sur l'après-midi du même jour.""" + """Event mardi 21:00→23:30 → retour mercredi 10:30 : arrivée cible du matin + 10:30, sortie 12:00, et le nominal qui ne tient pas (234-90=144 → 54 + manquantes) bascule sur l'après-midi du même jour.""" pointages = [ _pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"), - _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54", astreinte_fin="23:30"), + _pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54", + astreinte_events=[{"debut": "21:00", "fin": "23:30"}]), _pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"), @@ -206,14 +353,16 @@ def test_compute_week_arrivee_visee_ne_precede_jamais_le_retour(freeze_today): def test_compute_week_demi_journee_entierement_en_repos_compte_complete(freeze_today): - """Avec un quota de 6h/jour (180 par demi-journée), une fin à 02:00 (retour - 13:00) couvre toute la plage du matin : plus rien à y remplir, la demi-journée - est complète d'office et l'absence de pointage matin ne casse pas le cumul.""" + """Avec un quota de 6h/jour (180 par demi-journée), un retour 13:00 (event + 01:00→02:00 sur le mercredi, mardi astreinte) couvre toute la plage du matin : + plus rien à y remplir, la demi-journée est complète d'office et l'absence de + pointage matin ne casse pas le cumul.""" heures_jour = 360 pointages = [ _pointage("2026-07-13", "09:00", "12:00", "13:30", "16:30"), _pointage("2026-07-14", "09:00", "12:00", "13:30", "16:30"), - _pointage("2026-07-15", aprem_entree="13:30", aprem_sortie="16:30", astreinte_fin="02:00"), + _pointage("2026-07-15", aprem_entree="13:30", aprem_sortie="16:30", + astreinte_events=[{"debut": "01:00", "fin": "02:00"}]), _pointage("2026-07-16", "09:00", "12:00", "13:30", "16:30"), _pointage("2026-07-17", "09:00", "12:00", "13:30", "16:30"), ] @@ -233,3 +382,8 @@ def test_toggle_astreinte_persiste(): assert models.load_astreintes("u-astreinte") == ["2026-07-14"] models.toggle_astreinte("2026-07-14", "u-astreinte") assert models.load_astreintes("u-astreinte") == [] + + +def test_load_facteurs_astreinte_defaut_si_absent(): + facteurs = models.load_facteurs_astreinte() + assert any(f["nom"] == "Nuit" for f in facteurs) diff --git a/data/config.example.json b/data/config.example.json index faa566d..9747c2f 100644 --- a/data/config.example.json +++ b/data/config.example.json @@ -1,6 +1,12 @@ { "heures_semaine": 39.0, "jours_par_semaine": 5, + "astreinte_facteurs": [ + {"nom": "Nuit", "jours": ["semaine", "sam", "dim", "ferie"], "debut": "20:00", "fin": "08:00", "facteur": 1.5}, + {"nom": "Journée", "jours": ["semaine"], "debut": "08:00", "fin": "20:00", "facteur": 1.0}, + {"nom": "Samedi", "jours": ["sam"], "debut": "08:00", "fin": "20:00", "facteur": 1.5}, + {"nom": "Dimanche / férié", "jours": ["dim", "ferie"], "debut": "08:00", "fin": "20:00", "facteur": 2.0} + ], "log_level": "DEBUG", "allowed_email_domain": "exemple.fr", "base_url": "",