Retour décalé après astreinte : la présence obligatoire couverte compte comme effectuée

Quand le repos de 11h décale le retour au bureau (ex. 11:00), les heures de
présence obligatoire couvertes (ex. 09:00→11:00) sont créditées à la colonne
Travaillé au lieu d'être retirées des heures dues, qui restent complètes.
Le solde est inchangé, mais le calcul est explicite : badge « +2h00 repos »
sous le total (plages couvertes au survol) et rappel dans le bloc astreinte.
Rien n'est crédité sans heures dues (week-end, férié, congé), et le crédit
ne dépasse pas le nominal du jour.

Corrige aussi la perte des interventions postées sans description (le zip
s'arrêtait sur la liste vide des descriptions).

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-09 21:05:50 +02:00
parent 3d38abd853
commit 77ae115c4f
7 changed files with 143 additions and 24 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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'<span class="calc-num">{"—" if not jour["travaille_min"] else _e(jour["travaille"])}</span>'
if jour.get("couvert_min"):
trav_inner += (f'<span class="calc-couvert" title="Présence obligatoire couverte par le repos de 11h '
f'({_e(jour["couvert_detail"])}) : comptée comme effectuée">+{_e(jour["couvert_h"])} repos</span>')
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'<span class="as-hint" title="11h de repos consécutif après la dernière activité (durée arrondie au quart d\'heure supérieur), retour arrondi au quart d\'horloge supérieur : :00, :15, :30 ou :45">'
f'retour ≥ {_e(jour["retour"])}</span>')
if jour.get("couvert_min"):
hint += (f'<span class="as-hint" title="Présence obligatoire couverte par le repos ({_e(jour["couvert_detail"])}) : comptée comme effectuées dans la colonne Travaillé">'
f'+{_e(jour["couvert_h"])} de présence comptées</span>')
if jour.get("forfait_min"):
hint += (f'<span class="as-hint" title="Forfait du jour AS déclaré (réglé dans Réglages) : suivi dans le récapitulatif d&#39;astreinte, hors heures travaillées">'
f'forfait {_e(jour["forfait_h"])}</span>')
@ -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:

View File

@ -52,6 +52,7 @@
<td class="col-calc" data-label="Travaillé">
<span id="calc-{{ j.date }}-trav">
<span class="calc-num">{{ j.travaille if j.travaille_min else '—' }}</span>
{% if j.couvert_min %}<span class="calc-couvert" title="Présence obligatoire couverte par le repos de 11h ({{ j.couvert_detail }}) : comptée comme effectuée">+{{ j.couvert_h }} repos</span>{% endif %}
</span>
</td>
@ -146,6 +147,7 @@
<span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
</button>
{% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité (durée arrondie au quart d'heure supérieur), retour arrondi au quart d'horloge supérieur : :00, :15, :30 ou :45">retour ≥ {{ j.retour }}</span>{% endif %}
{% if j.couvert_min %}<span class="as-hint" title="Présence obligatoire couverte par le repos ({{ j.couvert_detail }}) : comptée comme effectuées dans la colonne Travaillé">+{{ j.couvert_h }} de présence comptées</span>{% endif %}
{% if j.forfait_min %}<span class="as-hint" title="Forfait du jour AS déclaré (Réglages) : suivi dans le récapitulatif d'astreinte, hors heures travaillées">forfait {{ j.forfait_h }}</span>{% endif %}
{% if j.astreinte_min %}<span class="as-total" title="Durée réelle des interventions arrondie au quart d'heure supérieur (2, 5 ou 12 min → 15 ; 22 ou 28 → 30), même sans tomber sur un quart d'heure de l'horloge — compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_h }}</span>{% endif %}
</div>

View File

@ -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); }

View File

@ -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 ───────────────────────────────────────────────────

View File

@ -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