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. 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. 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 À partir de cette heure de retour, la présence obligatoire couverte par le repos
décompte de la semaine : est **comptée comme effectuée** :
- la présence obligatoire avant l'heure de retour est retirée des heures dues - les heures de présence obligatoire avant l'heure de retour sont créditées au
(ex: retour 13:00 → la plage 09:00-12:00 ne compte plus) ; **Travaillé** du jour, affichées sous le total par un badge explicite
- une présence pointée avant l'heure de retour ne compte pas dans le travaillé ; (« +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 - les préconisations **Fin cible** démarrent à l'heure de retour (arrivée du matin
décalée d'autant, sans avertissement de retard) ; 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. - 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 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, 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 """Nominaux (attendus) matin et aprem en minutes, après congés, jour férié et
exclusion liée à l'heure de retour.""" exclusion liée à l'heure de retour."""
if ferie or "jour" in conges_date: 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 forfait = forfait_as_min if p["date"] in astreintes else 0
# Le forfait d'astreinte ne compte jamais dans les heures travaillées : # Le forfait d'astreinte ne compte jamais dans les heures travaillées :
# il est suivi à part dans le récapitulatif d'astreinte. # 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) # Les heures de présence obligatoire couvertes par le repos de 11h
du_full = du + matin_ex + aprem_ex # (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) matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie)
delta = travaille - du delta = travaille - du
total_travaille += travaille 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, "du_full_min": du_full,
"matin_excl_min": matin_ex, "matin_excl_min": matin_ex,
"aprem_excl_min": aprem_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, "matin_nominal_min": matin_nom,
"aprem_nominal_min": aprem_nom, "aprem_nominal_min": aprem_nom,
"astreinte": p["date"] in astreintes, "astreinte": p["date"] in astreintes,

View File

@ -33,7 +33,7 @@ from models import (
) )
from calcul import ( from calcul import (
compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues, 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, nominaux_du_jour, retour_apres_repos, decompte_astreinte, fenetre_astreinte_jour,
) )
from stats import compute_all_stats 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) 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) \ forfait = (hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0) \
if date in astreintes else 0 if date in astreintes else 0
trav = heures_travaillees(p, retour_min) # forfait d'astreinte : hors heures travaillées # Les heures de présence obligatoire couvertes par le repos de 11h sont
du = heures_dues(date, conges, h_jour, plages, retour_min, jour_ferie) # comptées comme effectuées : créditées au travaillé, dues complètes (le
du_full = du + matin_ex + aprem_ex # 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) matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie)
delta = trav - du delta = trav - du
# Décompte astreinte du jour (compteur séparé, hors heures de semaine) : # 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 "", "forfait_h": minutes_to_hhmm(forfait) if forfait else "",
"du_full_min": du_full, "du_full_min": du_full,
"matin_excl_min": matin_ex, "aprem_excl_min": aprem_ex, "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, "matin_nominal_min": matin_nom, "aprem_nominal_min": aprem_nom,
"astreinte": date in astreintes, "astreinte": date in astreintes,
"astreinte_qualifiee": jour_astreinte_qualifie(date, 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): def _oob_calc(date, jour):
trav_inner = f'<span class="calc-num">{"—" if not jour["travaille_min"] else _e(jour["travaille"])}</span>' 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"]: if jour["travaille_min"]:
cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero") 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 "" 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: 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">' 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>') 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"): 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">' 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>') 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. # 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))): if jour_astreinte_qualifie(date, set(load_astreintes_effectives(user_id))):
events = [] 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) deb, fin = clean(deb), clean(fin)
if deb and fin: if deb and fin:
desc = astreinte_desc[i] if i < len(astreinte_desc) else ""
events.append({"debut": deb, "fin": fin, events.append({"debut": deb, "fin": fin,
"desc": (desc or "").strip()[:200] or None}) "desc": (desc or "").strip()[:200] or None})
else: else:

View File

@ -52,6 +52,7 @@
<td class="col-calc" data-label="Travaillé"> <td class="col-calc" data-label="Travaillé">
<span id="calc-{{ j.date }}-trav"> <span id="calc-{{ j.date }}-trav">
<span class="calc-num">{{ j.travaille if j.travaille_min else '—' }}</span> <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> </span>
</td> </td>
@ -146,6 +147,7 @@
<span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }} <span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
</button> </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.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.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 %} {% 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> </div>

View File

@ -499,6 +499,20 @@
.col-calc { text-align: right; padding-left: .5rem; } .col-calc { text-align: right; padding-left: .5rem; }
.calc-num { font-family: var(--mono); font-size: 13px; color: var(--muted); } .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-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-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); } .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): 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 """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, 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 = [ pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"), _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-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"]) result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
# seul le pointé compte : 2 × 468 = 15h36, astreinte exclue # pointé (2 × 7h48) + 3h de présence couvertes créditées ; astreinte exclue
assert result["total_travaille"] == "15h36" assert result["total_travaille"] == "18h36"
assert result["jours"][2]["couvert_min"] == 180
ast = result["astreinte"] ast = result["astreinte"]
assert ast["actif"] is True 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): 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) : """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 retour mercredi 13:00, présence du matin (3h) créditée comme effectuée,
matin, après-midi 13:30→18:18 (nominal 288 = 234 + report).""" aucune cible matin, après-midi 13:30→18:18 (nominal 288 = 234 + report)."""
pointages = [ pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"), _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-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"]) result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
mercredi = result["jours"][2] 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["retour"] == "13:00" # 02:00 + 11h
assert mercredi["cible_matin"] is None assert mercredi["cible_matin"] is None
assert mercredi["cible_aprem"] == "18:18" 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): 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] mercredi = result["jours"][2]
assert mercredi["retour"] == "17:00" assert mercredi["retour"] == "17:00"
# matin (180) + 14:00→17:00 (180) exclus → dû = 108 # matin (180) + 14:00→17:00 (180) couverts → crédités 360, dues complètes 468
assert mercredi["du_min"] == 468 - 360 assert mercredi["du_min"] == 468
assert mercredi["couvert_min"] == 360
assert mercredi["couvert_h"] == "6h00"
assert mercredi["cible_matin"] is None assert mercredi["cible_matin"] is None
# aprem démarre à 17:00, nominal 108 (54 matin + 54 aprem) → 18:48 # aprem démarre à 17:00, nominal 108 (54 matin + 54 aprem) → 18:48
assert mercredi["cible_aprem_entree"] == "17:00" 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 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): 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 """Un event saisi un jour non qualifié (pas d'astreinte) ne change rien au
décompte ni au décompte d'astreinte.""" 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["matin_nominal_min"] == 0
assert mercredi["is_complete"] is True assert mercredi["is_complete"] is True
assert mercredi["cumul_visible"] 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 ─────────────────────────────────────────────────── # ── 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 # Le swap OOB régénère le bouton avec la pastille MA active
assert 'cg-chip st-ma">MA<' in r.text assert 'cg-chip st-ma">MA<' in r.text
assert 'id="cg-btn-' + TODAY + '"' 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