diff --git a/README.md b/README.md index e675693..11940d5 100644 --- a/README.md +++ b/README.md @@ -228,12 +228,16 @@ arrondie au quart supérieur : fin 23:30 → retour 10:30 le lendemain ; 22:39 23:49 (fin comptée 23:54) → retour 11:00 ; 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 : +À partir de cette heure de retour, la présence obligatoire couverte par le repos +est **comptée comme effectuée** : -- la présence obligatoire avant l'heure de retour est retirée des heures dues - (ex: retour 13:00 → la plage 09:00-12:00 ne compte plus) ; -- une présence pointée avant l'heure de retour ne compte pas dans le travaillé ; +- les heures de présence obligatoire avant l'heure de retour sont créditées au + **Travaillé** du jour, affichées sous le total par un badge explicite + (« +2h00 repos », détail des plages au survol — ex. retour 11:00 → la plage + 09:00-11:00 compte comme faite) ; les heures dues restent complètes, le solde + est le même que lorsqu'on retirait ces heures des dues ; +- une présence pointée avant l'heure de retour ne compte pas dans le travaillé + (seul le crédit de présence s'y ajoute) ; - les préconisations **Fin cible** démarrent à l'heure de retour (arrivée du matin 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. diff --git a/app/calcul.py b/app/calcul.py index 9a5a31e..f447ec2 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -108,8 +108,22 @@ def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict, fer return matin_ex, aprem_ex +def couvert_detail(plages: dict, retour_min: int | None, matin_ex: int, aprem_ex: int) -> str: + """Description des plages de présence obligatoire couvertes par le repos de + 11h (affichée avec le crédit d'heures effectuées). N'apparaît que couvert > 0, + donc retour_min défini.""" + parts = [] + if matin_ex: + fin = minutes_to_time(min(hhmm_to_minutes(plages["matin_fin"]), retour_min or 0)) + parts.append(f"matin {plages['matin_debut']}→{fin}") + if aprem_ex: + fin = minutes_to_time(min(hhmm_to_minutes(plages["aprem_fin"]), retour_min or 0)) + parts.append(f"après-midi {plages['aprem_debut']}→{fin}") + return ", ".join(parts) + + def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int, - ferie: bool = False) -> tuple[int, int]: + ferie: bool = False) -> tuple[int, int]: """Nominaux (attendus) matin et aprem en minutes, après congés, jour férié et exclusion liée à l'heure de retour.""" if ferie or "jour" in conges_date: @@ -470,9 +484,18 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int forfait = forfait_as_min if p["date"] in astreintes else 0 # Le forfait d'astreinte ne compte jamais dans les heures travaillées : # il est suivi à part dans le récapitulatif d'astreinte. - travaille = heures_travaillees(p, retour_min) - du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min, jour_ferie) - du_full = du + matin_ex + aprem_ex + # + # Les heures de présence obligatoire couvertes par le repos de 11h + # (matin_ex + aprem_ex) sont comptées comme effectuées : créditées au + # travaillé du jour, les heures dues restent complètes. Le solde est le + # même qu'en soustrayant couvert du dû, mais le calcul est explicite : + # travaillé = pointé + couvert, dû = nominal complet. Sans heures dues + # (week-end, férié, congé) il n'y a rien à créditer, et le crédit ne + # dépasse jamais le nominal du jour. + du_full = heures_dues(p["date"], conges, heures_jour_min, plages, None, jour_ferie) + couvert = min(matin_ex + aprem_ex, du_full) + travaille = heures_travaillees(p, retour_min) + couvert + du = du_full matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie) delta = travaille - du total_travaille += travaille @@ -492,6 +515,9 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "du_full_min": du_full, "matin_excl_min": matin_ex, "aprem_excl_min": aprem_ex, + "couvert_min": couvert, + "couvert_h": minutes_to_hhmm(couvert) if couvert else "", + "couvert_detail": couvert_detail(plages, retour_min, matin_ex, aprem_ex) if couvert else "", "matin_nominal_min": matin_nom, "aprem_nominal_min": aprem_nom, "astreinte": p["date"] in astreintes, diff --git a/app/main.py b/app/main.py index aa2a685..864354a 100644 --- a/app/main.py +++ b/app/main.py @@ -33,7 +33,7 @@ from models import ( ) from calcul import ( compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues, - heures_travaillees, jour_astreinte_qualifie, exclusion_retour, + heures_travaillees, jour_astreinte_qualifie, exclusion_retour, couvert_detail, nominaux_du_jour, retour_apres_repos, decompte_astreinte, fenetre_astreinte_jour, ) from stats import compute_all_stats @@ -351,9 +351,14 @@ def _build_jour_ctx(date, conges, h_jour, user_id): matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages, jour_ferie) forfait = (hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0) \ if date in astreintes else 0 - trav = heures_travaillees(p, retour_min) # forfait d'astreinte : hors heures travaillées - du = heures_dues(date, conges, h_jour, plages, retour_min, jour_ferie) - du_full = du + matin_ex + aprem_ex + # Les heures de présence obligatoire couvertes par le repos de 11h sont + # comptées comme effectuées : créditées au travaillé, dues complètes (le + # forfait d'astreinte, lui, reste hors heures travaillées). Sans heures + # dues (week-end, férié, congé), rien à créditer. + du_full = heures_dues(date, conges, h_jour, plages, None, jour_ferie) + couvert = min(matin_ex + aprem_ex, du_full) + trav = heures_travaillees(p, retour_min) + couvert + du = du_full matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie) delta = trav - du # Décompte astreinte du jour (compteur séparé, hors heures de semaine) : @@ -373,6 +378,9 @@ def _build_jour_ctx(date, conges, h_jour, user_id): "forfait_h": minutes_to_hhmm(forfait) if forfait else "", "du_full_min": du_full, "matin_excl_min": matin_ex, "aprem_excl_min": aprem_ex, + "couvert_min": couvert, + "couvert_h": minutes_to_hhmm(couvert) if couvert else "", + "couvert_detail": couvert_detail(plages, retour_min, matin_ex, aprem_ex) if couvert else "", "matin_nominal_min": matin_nom, "aprem_nominal_min": aprem_nom, "astreinte": date in astreintes, "astreinte_qualifiee": jour_astreinte_qualifie(date, astreintes), @@ -385,6 +393,9 @@ def _build_jour_ctx(date, conges, h_jour, user_id): def _oob_calc(date, jour): trav_inner = f'{"—" if not jour["travaille_min"] else _e(jour["travaille"])}' + if jour.get("couvert_min"): + trav_inner += (f'+{_e(jour["couvert_h"])} repos') if jour["travaille_min"]: cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero") sign = "+" if jour["delta_min"] > 0 else "" @@ -539,6 +550,9 @@ def _as_block_html(date, jour): if jour.get("retour_min") is not None: hint = (f'' f'retour ≥ {_e(jour["retour"])}') + if jour.get("couvert_min"): + hint += (f'' + f'+{_e(jour["couvert_h"])} de présence comptées') if jour.get("forfait_min"): hint += (f'' f'forfait {_e(jour["forfait_h"])}') @@ -843,9 +857,12 @@ def save_pointage( # au lieu de les écraser à vide. Les paires début/fin invalides sont ignorées. if jour_astreinte_qualifie(date, set(load_astreintes_effectives(user_id))): events = [] - for deb, fin, desc in zip(astreinte_debut, astreinte_fin, astreinte_desc): + # Les descriptions sont optionnelles : sans elles (POST programmatique), + # zip s'arrêterait à la liste la plus courte et perdrait les événements. + for i, (deb, fin) in enumerate(zip(astreinte_debut, astreinte_fin)): deb, fin = clean(deb), clean(fin) if deb and fin: + desc = astreinte_desc[i] if i < len(astreinte_desc) else "" events.append({"debut": deb, "fin": fin, "desc": (desc or "").strip()[:200] or None}) else: diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index fe5e011..9b3ef3a 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -52,6 +52,7 @@ {{ j.travaille if j.travaille_min else '—' }} + {% if j.couvert_min %}+{{ j.couvert_h }} repos{% endif %} @@ -146,6 +147,7 @@ ▸ {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }} {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} + {% if j.couvert_min %}+{{ j.couvert_h }} de présence comptées{% endif %} {% if j.forfait_min %}forfait {{ j.forfait_h }}{% endif %} {% if j.astreinte_min %}astreinte : {{ j.astreinte_h }}{% endif %} diff --git a/app/templates/base.html b/app/templates/base.html index 7c3a798..6e65e4d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -499,6 +499,20 @@ .col-calc { text-align: right; padding-left: .5rem; } .calc-num { font-family: var(--mono); font-size: 13px; color: var(--muted); } + /* Crédit d'heures de présence couvertes par le repos de 11h (retour décalé + après astreinte) : affiché sous le total travaillé, détail au survol. */ + .calc-couvert { + display: block; + font-family: var(--mono); + font-size: 10px; + color: var(--accent-2); + margin-top: .12rem; + margin-left: auto; + width: fit-content; + cursor: help; + border-bottom: 1px dotted rgba(184,106,8,.45); + white-space: normal; + } .delta-pos { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--pos); } .delta-neg { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--neg); } .delta-zero{ font-family: var(--mono); font-size: 13px; color: var(--rule); } diff --git a/app/tests/test_astreinte.py b/app/tests/test_astreinte.py index ad8af7e..eb39c4e 100644 --- a/app/tests/test_astreinte.py +++ b/app/tests/test_astreinte.py @@ -311,7 +311,8 @@ def test_nominaux_sans_exclusion_reste_la_moitié(): 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"].""" + seulement dans result["astreinte"] ; seules les 3h de présence obligatoire du + matin couvertes par le repos (retour 12:00) sont créditées comme effectuées.""" 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"}]), @@ -321,8 +322,9 @@ def test_compute_week_decompte_absent_des_heures_de_semaine(freeze_today): ] 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" + # pointé (2 × 7h48) + 3h de présence couvertes créditées ; astreinte exclue + assert result["total_travaille"] == "18h36" + assert result["jours"][2]["couvert_min"] == 180 ast = result["astreinte"] assert ast["actif"] is True @@ -341,8 +343,8 @@ def test_compute_week_decompte_absent_des_heures_de_semaine(freeze_today): def test_compute_week_fin_nocturne_decale_le_decompte_et_les_cibles(freeze_today): """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).""" + retour mercredi 13:00, présence du matin (3h) créditée comme effectuée, + 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"), @@ -353,11 +355,16 @@ def test_compute_week_fin_nocturne_decale_le_decompte_et_les_cibles(freeze_today result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) mercredi = result["jours"][2] - assert mercredi["du_min"] == 468 - 180 # matin 09:00→12:00 exclu par le retour 13:00 + # dues complètes, présence matin 09:00→12:00 couverte créditée au travaillé + assert mercredi["du_min"] == 468 + assert mercredi["couvert_min"] == 180 + assert mercredi["travaille_min"] == 180 + assert mercredi["couvert_detail"] == "matin 09:00→12: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"] == "39h00" # dues nominales complètes + assert result["total_travaille"] == "18h36" # 2 × 7h48 + 3h de présence créditée def test_compute_week_nuit_finissant_au_matin(freeze_today): @@ -375,8 +382,10 @@ def test_compute_week_nuit_finissant_au_matin(freeze_today): mercredi = result["jours"][2] assert mercredi["retour"] == "17:00" - # matin (180) + 14:00→17:00 (180) exclus → dû = 108 - assert mercredi["du_min"] == 468 - 360 + # matin (180) + 14:00→17:00 (180) couverts → crédités 360, dues complètes 468 + assert mercredi["du_min"] == 468 + assert mercredi["couvert_min"] == 360 + assert mercredi["couvert_h"] == "6h00" assert mercredi["cible_matin"] is None # aprem démarre à 17:00, nominal 108 (54 matin + 54 aprem) → 18:48 assert mercredi["cible_aprem_entree"] == "17:00" @@ -386,6 +395,32 @@ def test_compute_week_nuit_finissant_au_matin(freeze_today): assert result["astreinte"]["total_pondere_min"] == 630 +def test_compute_week_presence_couvertes_comptees_effectuees(freeze_today): + """Retour décalé à 11:00 (event mardi 22:39→23:49) : les 2h de présence + obligatoire du matin (09:00→11:00) sont comptées comme effectuées — + créditées au travaillé, dues complètes, Δ inchangé par rapport à + l'ancienne soustraction des dues.""" + 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_events=[{"debut": "22:39", "fin": "23:49"}]), + _pointage("2026-07-15", "11:00", "12:00", "14:00", "17: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["retour"] == "11:00" + assert mercredi["couvert_min"] == 120 # présence 09:00→11:00 + assert mercredi["couvert_h"] == "2h00" + assert mercredi["couvert_detail"] == "matin 09:00→11:00" + assert mercredi["travaille_min"] == 240 + 120 # pointé (1h + 3h) + couvert + assert mercredi["travaille"] == "6h00" + assert mercredi["du_min"] == 468 # dues complètes + assert mercredi["delta_min"] == 360 - 468 + + 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.""" @@ -447,7 +482,8 @@ def test_compute_week_demi_journee_entierement_en_repos_compte_complete(freeze_t assert mercredi["matin_nominal_min"] == 0 assert mercredi["is_complete"] is True assert mercredi["cumul_visible"] is True - assert mercredi["du_min"] == 180 # 6h dues - 3h de présence matin retirées + assert mercredi["du_min"] == 360 # dues complètes du jour (6h) + assert mercredi["couvert_min"] == 180 # 3h de présence matin couvertes, créditées # ── Stockage des astreintes ─────────────────────────────────────────────────── diff --git a/app/tests/test_ui_refonte.py b/app/tests/test_ui_refonte.py index 22f2479..36b93bc 100644 --- a/app/tests/test_ui_refonte.py +++ b/app/tests/test_ui_refonte.py @@ -76,3 +76,23 @@ def test_bascule_congen_regenere_le_menu_d_etat(monkeypatch): # Le swap OOB régénère le bouton avec la pastille MA active assert 'cg-chip st-ma">MA<' in r.text assert 'id="cg-btn-' + TODAY + '"' in r.text + + +def test_retour_decale_affiche_le_credit_de_presence(monkeypatch): + """Retour mercredi 11:00 (event mardi 22:39→23:49) : la colonne Travaillé + crédite les 2h de présence couvertes, avec badge et détail explicites.""" + monkeypatch.setattr(main, "datetime", _FrozenDatetime) + cookies = _login("u-ui-credit") + client.post("/astreinte/2026-07-14/toggle", cookies=cookies, follow_redirects=False) + r = client.post("/pointage/2026-07-14", cookies=cookies, + data={"astreinte_debut": ["22:39"], "astreinte_fin": ["23:49"]}, + headers={"HX-Request": "true"}, follow_redirects=False) + assert r.status_code == 200 + + # Le crédit s'affiche sur la ligne du lendemain (jour du retour décalé) + r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False) + assert r.status_code == 200 + assert 'class="calc-couvert"' in r.text + assert "+2h00 repos" in r.text + assert "matin 09:00→11:00" in r.text # détail des plages couvertes + assert "de présence comptées" in r.text # rappel dans le bloc astreinte