Respecte la pause déjeuner dans les préconisations et ajoute les heures visées
La cible matin ne dépasse plus jamais matin_fin, garantissant la pause de midi ; le nominal qui déborde bascule sur l'après-midi du même jour. Ajoute deux réglages optionnels par utilisateur (arrivée visée le matin, départ visé le soir) qui orientent la répartition du rattrapage en deux passes : d'abord dans le cadre visé, puis au-delà si nécessaire. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -7,6 +7,8 @@ DEFAULT_PLAGES = {
|
|||||||
"aprem_debut": "14:00", # présence obligatoire à partir de cette heure l'après-midi
|
"aprem_debut": "14:00", # présence obligatoire à partir de cette heure l'après-midi
|
||||||
"aprem_fin": "17:00", # présence obligatoire jusqu'à cette heure l'après-midi (plancher de sortie)
|
"aprem_fin": "17:00", # présence obligatoire jusqu'à cette heure l'après-midi (plancher de sortie)
|
||||||
"pause_dejeuner_fin": "13:30", # reprise par défaut projetée tant que l'entrée aprem n'est pas saisie
|
"pause_dejeuner_fin": "13:30", # reprise par défaut projetée tant que l'entrée aprem n'est pas saisie
|
||||||
|
"arrivee_visee": "", # arrivée du matin visée pour les préconisations (vide = matin_debut)
|
||||||
|
"depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -75,6 +77,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
|||||||
matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
|
matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
|
||||||
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
|
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
|
||||||
pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"])
|
pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"])
|
||||||
|
arrivee_visee_min = hhmm_to_minutes(plages["arrivee_visee"]) if plages.get("arrivee_visee") else matin_debut_min
|
||||||
|
depart_vise_min = hhmm_to_minutes(plages["depart_vise"]) if plages.get("depart_vise") else None
|
||||||
|
|
||||||
total_travaille = 0
|
total_travaille = 0
|
||||||
total_du = 0
|
total_du = 0
|
||||||
@ -117,8 +121,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
|||||||
jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok
|
jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok
|
||||||
|
|
||||||
# Préconisations : répartit le manque restant à parts égales sur les demi-journées
|
# Préconisations : répartit le manque restant à parts égales sur les demi-journées
|
||||||
# restantes, dans la limite de la capacité de chaque créneau (le matin est plafonné
|
# restantes, dans la limite de la capacité de chaque créneau. La pause de midi
|
||||||
# à la reprise de l'après-midi, l'après-midi à minuit).
|
# (matin_fin → reprise, 12:00 → 13:30 par défaut) est intouchable : la cible
|
||||||
|
# matin ne dépasse jamais matin_fin, et le nominal matin qui n'y tient pas
|
||||||
|
# bascule sur l'après-midi du même jour. Le matin démarre à l'arrivée visée
|
||||||
|
# (sinon matin_debut) ; l'après-midi vise le départ du soir configuré (plafond
|
||||||
|
# souple : dépassé seulement si le rattrapage ne tient nulle part avant), avec
|
||||||
|
# minuit comme limite dure.
|
||||||
for jour in jours:
|
for jour in jours:
|
||||||
jour["cible_matin"] = None
|
jour["cible_matin"] = None
|
||||||
jour["cible_aprem"] = None
|
jour["cible_aprem"] = None
|
||||||
@ -154,30 +163,41 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
|||||||
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg
|
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg
|
||||||
aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg
|
aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg
|
||||||
if not matin_done:
|
if not matin_done:
|
||||||
debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else matin_debut_min
|
debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else arrivee_visee_min
|
||||||
slots.append({"jour": jour, "half": "matin", "nominal": matin_nominal,
|
reprise = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
|
||||||
"debut": debut, "cap": max(0, pause_dejeuner_fin_min - debut)})
|
cap = max(0, min(matin_fin_min, reprise) - debut)
|
||||||
|
# Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi
|
||||||
|
# du même jour (s'il est encore ouvert), sinon dans le rattrapage global.
|
||||||
|
surplus = max(0, matin_nominal - cap)
|
||||||
|
if surplus and not aprem_done:
|
||||||
|
aprem_nominal += surplus
|
||||||
|
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap),
|
||||||
|
"debut": debut, "cap": cap, "cap_pref": cap})
|
||||||
if not aprem_done:
|
if not aprem_done:
|
||||||
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
|
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
|
||||||
|
cap = max(0, 23 * 60 + 59 - debut)
|
||||||
|
cap_pref = min(cap, max(0, depart_vise_min - debut)) if depart_vise_min else cap
|
||||||
slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal,
|
slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal,
|
||||||
"debut": debut, "cap": max(0, 23 * 60 + 59 - debut)})
|
"debut": debut, "cap": cap, "cap_pref": cap_pref})
|
||||||
|
|
||||||
extra_total = max(0, restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
|
extra_total = max(0, restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
|
||||||
slots_restants = len(slots)
|
slots_restants = len(slots)
|
||||||
|
|
||||||
# Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau :
|
# Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau,
|
||||||
# le surplus d'un créneau saturé se reporte sur les autres.
|
# en deux passes : d'abord dans le cadre visé (avant le départ du soir), puis
|
||||||
|
# au-delà si ça ne tient pas. Le surplus d'un créneau saturé se reporte sur les autres.
|
||||||
adjs = [0] * slots_restants
|
adjs = [0] * slots_restants
|
||||||
left = extra_total
|
left = extra_total
|
||||||
while left > 0:
|
for cap_key in ("cap_pref", "cap"):
|
||||||
open_idx = [i for i, s in enumerate(slots) if adjs[i] < s["cap"] - s["nominal"]]
|
while left > 0:
|
||||||
if not open_idx:
|
open_idx = [i for i, s in enumerate(slots) if adjs[i] < s[cap_key] - s["nominal"]]
|
||||||
break # tous les créneaux saturés : rattrapage partiellement impossible cette semaine
|
if not open_idx:
|
||||||
base, rem = divmod(left, len(open_idx))
|
break # tous les créneaux saturés à ce plafond : passe suivante, ou rattrapage impossible
|
||||||
for k, i in enumerate(open_idx):
|
base, rem = divmod(left, len(open_idx))
|
||||||
give = min(slots[i]["cap"] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
|
for k, i in enumerate(open_idx):
|
||||||
adjs[i] += give
|
give = min(slots[i][cap_key] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
|
||||||
left -= give
|
adjs[i] += give
|
||||||
|
left -= give
|
||||||
|
|
||||||
for i, s in enumerate(slots):
|
for i, s in enumerate(slots):
|
||||||
if adjs[i] <= 0:
|
if adjs[i] <= 0:
|
||||||
|
|||||||
10
app/main.py
10
app/main.py
@ -599,6 +599,7 @@ def settings_plages_save(
|
|||||||
matin_debut: str = Form(""), matin_fin: str = Form(""),
|
matin_debut: str = Form(""), matin_fin: str = Form(""),
|
||||||
aprem_debut: str = Form(""), aprem_fin: str = Form(""),
|
aprem_debut: str = Form(""), aprem_fin: str = Form(""),
|
||||||
pause_dejeuner_fin: str = Form(""),
|
pause_dejeuner_fin: str = Form(""),
|
||||||
|
arrivee_visee: str = Form(""), depart_vise: str = Form(""),
|
||||||
):
|
):
|
||||||
user_id = _get_user(request)
|
user_id = _get_user(request)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@ -608,13 +609,20 @@ def settings_plages_save(
|
|||||||
"matin_debut": matin_debut, "matin_fin": matin_fin,
|
"matin_debut": matin_debut, "matin_fin": matin_fin,
|
||||||
"aprem_debut": aprem_debut, "aprem_fin": aprem_fin,
|
"aprem_debut": aprem_debut, "aprem_fin": aprem_fin,
|
||||||
"pause_dejeuner_fin": pause_dejeuner_fin,
|
"pause_dejeuner_fin": pause_dejeuner_fin,
|
||||||
|
# Heures visées, optionnelles (vide = comportement par défaut)
|
||||||
|
"arrivee_visee": arrivee_visee, "depart_vise": depart_vise,
|
||||||
}
|
}
|
||||||
if not all(_HHMM_RE.match(v) for v in fields.values()):
|
optionnels = {"arrivee_visee", "depart_vise"}
|
||||||
|
if not all(_HHMM_RE.match(v) for k, v in fields.items() if v or k not in optionnels):
|
||||||
logger.warning("réglages plages rejetés (format invalide): user_id=%r valeurs=%r", user_id, fields)
|
logger.warning("réglages plages rejetés (format invalide): user_id=%r valeurs=%r", user_id, fields)
|
||||||
return RedirectResponse("/settings?plages_error=1", status_code=303)
|
return RedirectResponse("/settings?plages_error=1", status_code=303)
|
||||||
if not (fields["matin_debut"] < fields["matin_fin"] < fields["aprem_debut"] < fields["aprem_fin"]):
|
if not (fields["matin_debut"] < fields["matin_fin"] < fields["aprem_debut"] < fields["aprem_fin"]):
|
||||||
logger.warning("réglages plages rejetés (ordre invalide): user_id=%r valeurs=%r", user_id, fields)
|
logger.warning("réglages plages rejetés (ordre invalide): user_id=%r valeurs=%r", user_id, fields)
|
||||||
return RedirectResponse("/settings?plages_error=1", status_code=303)
|
return RedirectResponse("/settings?plages_error=1", status_code=303)
|
||||||
|
if (fields["arrivee_visee"] and fields["arrivee_visee"] >= fields["matin_fin"]) or \
|
||||||
|
(fields["depart_vise"] and fields["depart_vise"] < fields["aprem_fin"]):
|
||||||
|
logger.warning("réglages plages rejetés (heures visées incohérentes): user_id=%r valeurs=%r", user_id, fields)
|
||||||
|
return RedirectResponse("/settings?plages_error=1", status_code=303)
|
||||||
|
|
||||||
logger.info("réglages plages mis à jour: user_id=%r plages=%r", user_id, fields)
|
logger.info("réglages plages mis à jour: user_id=%r plages=%r", user_id, fields)
|
||||||
save_plages(user_id, fields)
|
save_plages(user_id, fields)
|
||||||
|
|||||||
@ -12,7 +12,10 @@
|
|||||||
<p class="settings-p">
|
<p class="settings-p">
|
||||||
Bornes de présence obligatoire matin/après-midi et heure de reprise par défaut de
|
Bornes de présence obligatoire matin/après-midi et heure de reprise par défaut de
|
||||||
l'après-midi, utilisées pour les avertissements de saisie et les préconisations de
|
l'après-midi, utilisées pour les avertissements de saisie et les préconisations de
|
||||||
fin de demi-journée. Propres à ton compte — ne change rien pour les autres.
|
fin de demi-journée. Les heures visées (arrivée le matin, départ le soir) guident
|
||||||
|
les préconisations : le matin est planifié depuis ton arrivée visée, et le rattrapage
|
||||||
|
est casé avant ton départ visé tant que c'est possible. Propres à ton compte —
|
||||||
|
ne change rien pour les autres.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if plages_saved %}
|
{% if plages_saved %}
|
||||||
@ -37,6 +40,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="login-label" for="pause_dejeuner_fin">Reprise par défaut de l'après-midi</label>
|
<label class="login-label" for="pause_dejeuner_fin">Reprise par défaut de l'après-midi</label>
|
||||||
<input id="pause_dejeuner_fin" class="login-input time-range-input" type="time" name="pause_dejeuner_fin" value="{{ plages.pause_dejeuner_fin }}">
|
<input id="pause_dejeuner_fin" class="login-input time-range-input" type="time" name="pause_dejeuner_fin" value="{{ plages.pause_dejeuner_fin }}">
|
||||||
|
<label class="login-label" for="arrivee_visee">Heures visées arrivée / départ (optionnel)</label>
|
||||||
|
<div class="settings-range">
|
||||||
|
<input id="arrivee_visee" class="login-input time-range-input" type="time" name="arrivee_visee" value="{{ plages.arrivee_visee }}">
|
||||||
|
<span class="settings-range-sep">→</span>
|
||||||
|
<input id="depart_vise" class="login-input time-range-input" type="time" name="depart_vise" value="{{ plages.depart_vise }}" aria-label="Départ visé le soir">
|
||||||
|
</div>
|
||||||
<button class="login-btn" type="submit">Enregistrer</button>
|
<button class="login-btn" type="submit">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -180,8 +180,9 @@ def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today)
|
|||||||
comme capacité restante — ce qui masquait le rattrapage pendant la journée.
|
comme capacité restante — ce qui masquait le rattrapage pendant la journée.
|
||||||
|
|
||||||
Lundi en retard de 60 min, mardi-jeudi complets, vendredi (aujourd'hui, gelé à
|
Lundi en retard de 60 min, mardi-jeudi complets, vendredi (aujourd'hui, gelé à
|
||||||
10:30) matin en cours depuis 09:00. Les 60 min doivent être réparties : le matin
|
10:30) matin en cours depuis 09:00. Le matin s'arrête à la pause de midi
|
||||||
est plafonné par la pause déjeuner (max +36), donc 30/30 passe sans saturation.
|
(nominal plafonné à 12:00, l'excédent bascule sur l'après-midi) : les 60 min
|
||||||
|
de rattrapage vont entièrement sur l'après-midi.
|
||||||
"""
|
"""
|
||||||
pointages = [
|
pointages = [
|
||||||
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:54"), # -60 min
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:54"), # -60 min
|
||||||
@ -193,18 +194,19 @@ def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today)
|
|||||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||||
vendredi = result["jours"][4]
|
vendredi = result["jours"][4]
|
||||||
assert result["extra_min"] == 60
|
assert result["extra_min"] == 60
|
||||||
# matin : 09:00 + 234 nominal + 30 = 13:24 ; aprem : 13:30 + 234 + 30 = 17:54
|
# matin saturé au nominal (09:00→12:00), pas de cible ; aprem : 13:30 + 288 + 60 = 19:18
|
||||||
assert vendredi["cible_matin"] == "13:24"
|
assert vendredi["cible_matin"] is None
|
||||||
assert vendredi["cible_aprem"] == "17:54"
|
assert vendredi["cible_aprem"] == "19:18"
|
||||||
|
|
||||||
|
|
||||||
def test_compute_week_cible_matin_plafonnee_a_la_pause_dejeuner(freeze_today):
|
def test_compute_week_cible_matin_ne_mange_jamais_la_pause_de_midi(freeze_today):
|
||||||
"""La cible matin ne dépasse jamais la reprise de l'après-midi : un créneau
|
"""La cible matin ne dépasse jamais matin_fin : la pause de midi (12:00→13:30)
|
||||||
saturé reçoit sa capacité max, le surplus reste non alloué (pas de cible absurde).
|
reste libre, même quand le retard ne peut pas être rattrapé ailleurs.
|
||||||
|
|
||||||
Vendredi en congé après-midi : seule sa matinée reste (nominal 234, capacité
|
Vendredi en congé après-midi : seule sa matinée reste, plafonnée à 09:00→12:00
|
||||||
09:00→13:30 = 270, soit +36 max). Avec 120 min de retard, la cible est plafonnée
|
(180 min, sous les 234 nominales). Avec 120 min de retard sur lundi, aucune
|
||||||
à 13:30 au lieu d'un impossible 14:54.
|
cible n'est proposée (le créneau est déjà saturé) plutôt qu'une sortie à 13:30
|
||||||
|
qui supprimerait la pause ; le manque non plaçable reste visible dans extra.
|
||||||
"""
|
"""
|
||||||
conges = [{"date": "2026-07-17", "type": "aprem"}]
|
conges = [{"date": "2026-07-17", "type": "aprem"}]
|
||||||
pointages = [
|
pointages = [
|
||||||
@ -216,17 +218,20 @@ def test_compute_week_cible_matin_plafonnee_a_la_pause_dejeuner(freeze_today):
|
|||||||
]
|
]
|
||||||
result = compute_week(pointages, conges, HEURES_JOUR_MIN)
|
result = compute_week(pointages, conges, HEURES_JOUR_MIN)
|
||||||
vendredi = result["jours"][4]
|
vendredi = result["jours"][4]
|
||||||
assert result["extra_min"] == 120
|
# 120 min de retard + 54 min de nominal matin qui ne tiennent pas avant la pause
|
||||||
assert vendredi["cible_matin"] == "13:30"
|
# (et ne peuvent pas basculer sur l'après-midi, en congé)
|
||||||
|
assert result["extra_min"] == 174
|
||||||
|
assert vendredi["cible_matin"] is None
|
||||||
assert vendredi["cible_aprem"] is None # congé
|
assert vendredi["cible_aprem"] is None # congé
|
||||||
|
|
||||||
|
|
||||||
def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today):
|
def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today):
|
||||||
"""Quand un créneau matin sature (plafond pause déjeuner), le surplus doit se
|
"""Quand le créneau matin sature (plafond pause de midi), le rattrapage doit se
|
||||||
reporter sur les créneaux encore ouverts au lieu d'être perdu.
|
reporter sur les créneaux encore ouverts au lieu d'être perdu.
|
||||||
|
|
||||||
80 min de retard sur 2 demi-journées de vendredi : réparti 40/40, mais le matin
|
80 min de retard, vendredi entièrement vide : le matin est déjà saturé à son
|
||||||
plafonne à +36 — les 4 min restantes basculent sur l'après-midi (+44).
|
plafond 09:00→12:00 (nominal 180, excédent 54 basculé sur l'après-midi qui
|
||||||
|
passe à 288), donc tout le rattrapage va sur l'après-midi.
|
||||||
"""
|
"""
|
||||||
pointages = [
|
pointages = [
|
||||||
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:34"), # -80 min
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:34"), # -80 min
|
||||||
@ -238,9 +243,90 @@ def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today
|
|||||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||||
vendredi = result["jours"][4]
|
vendredi = result["jours"][4]
|
||||||
assert result["extra_min"] == 80
|
assert result["extra_min"] == 80
|
||||||
# matin saturé : 09:00 + 270 = 13:30 ; aprem : 13:30 + 234 + 44 = 18:08
|
# matin saturé au nominal, pas de cible ; aprem : 13:30 + 288 + 80 = 19:38
|
||||||
assert vendredi["cible_matin"] == "13:30"
|
assert vendredi["cible_matin"] is None
|
||||||
assert vendredi["cible_aprem"] == "18:08"
|
assert vendredi["cible_aprem"] == "19:38"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_cible_matin_possible_quand_la_plage_matin_a_de_la_marge(freeze_today):
|
||||||
|
"""Avec des plages personnalisées où la matinée obligatoire est plus longue que
|
||||||
|
le nominal, une cible matin peut être proposée — mais jamais au-delà de
|
||||||
|
matin_fin, pour que la pause (matin_fin → reprise) reste libre.
|
||||||
|
|
||||||
|
Matin 08:00→13:00 (capacité 300 > nominal 270). Vendredi en congé après-midi,
|
||||||
|
120 min de retard : le matin reçoit +30 max (plafond 13:00), le reste n'est
|
||||||
|
pas plaçable. La pause 13:00→14:00 reste intacte.
|
||||||
|
"""
|
||||||
|
plages = {
|
||||||
|
"matin_debut": "08:00", "matin_fin": "13:00",
|
||||||
|
"aprem_debut": "14:00", "aprem_fin": "18:00",
|
||||||
|
"pause_dejeuner_fin": "14:00",
|
||||||
|
}
|
||||||
|
heures_jour = 540
|
||||||
|
conges = [{"date": "2026-07-17", "type": "aprem"}]
|
||||||
|
pointages = [
|
||||||
|
_pointage("2026-07-13", "08:00", "13:00", "14:00", "16:00"), # -120 min
|
||||||
|
_pointage("2026-07-14", "08:00", "13:00", "14:00", "18:00"),
|
||||||
|
_pointage("2026-07-15", "08:00", "13:00", "14:00", "18:00"),
|
||||||
|
_pointage("2026-07-16", "08:00", "13:00", "14:00", "18:00"),
|
||||||
|
_pointage("2026-07-17"),
|
||||||
|
]
|
||||||
|
result = compute_week(pointages, conges, heures_jour, plages)
|
||||||
|
vendredi = result["jours"][4]
|
||||||
|
assert result["extra_min"] == 120
|
||||||
|
# 08:00 + 270 nominal + 30 (saturé au plafond matin_fin) = 13:00
|
||||||
|
assert vendredi["cible_matin"] == "13:00"
|
||||||
|
assert vendredi["cible_aprem"] is None # congé
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_arrivee_visee_etend_le_creneau_matin(freeze_today):
|
||||||
|
"""L'arrivée visée sert de début projeté du matin : en visant 08:00, le créneau
|
||||||
|
matin gagne de la capacité (08:00→12:00 = 240 ≥ 234 nominal) et peut recevoir
|
||||||
|
du rattrapage — toujours sans dépasser matin_fin.
|
||||||
|
"""
|
||||||
|
plages = {"arrivee_visee": "08:00"}
|
||||||
|
conges = [{"date": "2026-07-17", "type": "aprem"}]
|
||||||
|
pointages = [
|
||||||
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "15:54"), # -120 min
|
||||||
|
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
|
||||||
|
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
|
||||||
|
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
|
||||||
|
_pointage("2026-07-17"),
|
||||||
|
]
|
||||||
|
result = compute_week(pointages, conges, HEURES_JOUR_MIN, plages)
|
||||||
|
vendredi = result["jours"][4]
|
||||||
|
assert result["extra_min"] == 120
|
||||||
|
# 08:00 + 234 nominal + 6 (saturé au plafond matin_fin) = 12:00
|
||||||
|
assert vendredi["cible_matin"] == "12:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_depart_vise_plafond_souple(freeze_today):
|
||||||
|
"""Le départ visé concentre d'abord le rattrapage sur les créneaux qui tiennent
|
||||||
|
avant cette heure ; il n'est dépassé que si ça ne tient nulle part (plafond souple).
|
||||||
|
|
||||||
|
Vendredi matin pointé 09:00→12:00, son après-midi reste (nominal 234) ; lundi
|
||||||
|
suivant vide (matin 180 saturé, aprem 288). Rattrapage : 54 min. Sans départ
|
||||||
|
visé, réparti 27/27. Avec départ visé 18:00 : l'aprem de vendredi (qui a de la
|
||||||
|
marge avant 18:00) absorbe d'abord 36, puis la 2e passe répartit le reste 9/9.
|
||||||
|
"""
|
||||||
|
plages = {"depart_vise": "18: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"),
|
||||||
|
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
|
||||||
|
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
|
||||||
|
_pointage("2026-07-17", "09:00", "12:00"),
|
||||||
|
_pointage("2026-07-20"), # lundi suivant, vide
|
||||||
|
]
|
||||||
|
result = compute_week(pointages, [], HEURES_JOUR_MIN, plages)
|
||||||
|
vendredi = result["jours"][4]
|
||||||
|
lundi = result["jours"][5]
|
||||||
|
assert result["extra_min"] == 54
|
||||||
|
# vendredi aprem : 13:30 + 234 + (36 + 9) = 18:09 ; lundi aprem : 13:30 + 288 + 9 = 18:27
|
||||||
|
# (sans départ visé, lundi finirait à 18:45)
|
||||||
|
assert vendredi["cible_aprem"] == "18:09"
|
||||||
|
assert lundi["cible_matin"] is None
|
||||||
|
assert lundi["cible_aprem"] == "18:27"
|
||||||
|
|
||||||
|
|
||||||
def test_compute_week_plages_personnalisees(freeze_today):
|
def test_compute_week_plages_personnalisees(freeze_today):
|
||||||
|
|||||||
Reference in New Issue
Block a user