Préconisations d'horaire : cascade d'avance et correctif de double comptage
Une avance que les départs du soir ne peuvent pas écouler (plancher de
présence) s'écoule désormais en cascade miroir du rattrapage : arrivées
du matin retardées, puis retours de midi, toujours bornés par les plages
de présence.
Une demi-journée en cours qui dépasse son nominal (ex. sortie de midi
oubliée) comptait son temps écoulé deux fois : suivre les cibles
sur-livrait jusqu'à 1h45 par semaine. Le créneau saturé propose
maintenant « sortir maintenant » et l'écart se reporte sur les autres
créneaux ; plus de retard fantôme affiché.
Le README liste désormais l'ensemble des règles de préconisation.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
102
app/calcul.py
102
app/calcul.py
@ -613,6 +613,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
forfait_as_min = hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0
|
||||
matin_debut_min = hhmm_to_minutes(plages["matin_debut"])
|
||||
matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
|
||||
aprem_debut_min = hhmm_to_minutes(plages["aprem_debut"])
|
||||
aprem_fin_min = hhmm_to_minutes(plages["aprem_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
|
||||
@ -773,8 +774,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
# début de « Présence matin », l'heure de retour après repos, ni avant
|
||||
# maintenant pour le jour en cours), puis avancer les retours de midi (la
|
||||
# pause peut être raccourcie, jamais avant la fin de « Présence matin »).
|
||||
# En cas d'avance, on avance les départs symétriquement, sans jamais
|
||||
# descendre sous le plancher aprem_fin (présence minimale obligatoire).
|
||||
# En cas d'avance, cascade miroir : départs du soir avancés (jamais sous la
|
||||
# fin de « Présence après-midi »), puis arrivées du matin retardées (jamais
|
||||
# après le début de « Présence matin »), puis retours de midi retardés
|
||||
# (jamais après le début de « Présence après-midi » : la pause s'allonge).
|
||||
# Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi.
|
||||
for jour in jours:
|
||||
jour["cible_matin"] = None
|
||||
@ -784,23 +787,19 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
|
||||
restant = total_du - total_travaille
|
||||
|
||||
# Une demi-journée en cours (entrée pointée sans sortie, aujourd'hui) est comptée
|
||||
# ci-dessous comme un créneau entier à faire depuis son heure d'entrée ; son temps
|
||||
# déjà écoulé, inclus dans total_travaille, doit donc être réintégré au besoin,
|
||||
# sinon la même période compte à la fois comme travaillée et comme à venir.
|
||||
today = datetime.today().strftime("%Y-%m-%d")
|
||||
now_min = datetime.today().hour * 60 + datetime.today().minute
|
||||
restant_planif = restant
|
||||
for jour in jours:
|
||||
if jour["date"] != today:
|
||||
continue
|
||||
retour = jour.get("retour_min") or 0
|
||||
if jour.get("matin_entree") and not jour.get("matin_sortie"):
|
||||
restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["matin_entree"]), retour))
|
||||
if jour.get("aprem_entree") and not jour.get("aprem_sortie"):
|
||||
restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["aprem_entree"]), retour))
|
||||
|
||||
slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage
|
||||
# restant_planif : reste à planifier. Une demi-journée en cours (entrée pointée
|
||||
# sans sortie, aujourd'hui) est comptée ci-dessous comme un créneau entier à
|
||||
# faire depuis son heure d'entrée ; son temps déjà écoulé, inclus dans
|
||||
# total_travaille, doit donc être réintégré, sinon la même période compte à la
|
||||
# fois comme travaillée et comme à venir. Sauf si le créneau est saturé (temps
|
||||
# écoulé ≥ nominal) : la sortie cible retombe alors à « maintenant », la période
|
||||
# n'est pas re-planifiée, et son excédent déjà travaillé est de facto compté —
|
||||
# le réintégrer ferait doubler le compte.
|
||||
restant_planif = restant
|
||||
for jour in jours:
|
||||
# Un jour passé non renseigné n'est pas exclu : cette appli permet de saisir
|
||||
# ses heures après coup, donc une demi-journée vide reste "à faire" quelle
|
||||
@ -813,6 +812,14 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
# (retour au-delà de minuit suivant) : rien à planifier ce jour-là.
|
||||
if (jour.get("retour_min") or 0) >= 24 * 60:
|
||||
continue
|
||||
# Temps déjà écoulé des demi-journées en cours aujourd'hui (entrée pointée
|
||||
# sans sortie) — cf. restant_planif ci-dessus.
|
||||
ecoule = {}
|
||||
if jour["date"] == today:
|
||||
retour = jour.get("retour_min") or 0
|
||||
for half in ("matin", "aprem"):
|
||||
if jour.get(f"{half}_entree") and not jour.get(f"{half}_sortie"):
|
||||
ecoule[half] = max(0, now_min - max(hhmm_to_minutes(jour[f"{half}_entree"]), retour))
|
||||
matin_nominal = jour["matin_nominal_min"]
|
||||
aprem_nominal = jour["aprem_nominal_min"]
|
||||
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg
|
||||
@ -829,8 +836,15 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
aprem_nominal += surplus
|
||||
# Le matin n'est jamais ajusté par le rattrapage hebdomadaire : seule sa
|
||||
# capacité avant la pause de midi compte, pour le calcul du surplus ci-dessus.
|
||||
if cap > 0 and matin_nominal > 0:
|
||||
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
|
||||
# Une demi-journée en cours garde toujours son créneau : l'entrée ouverte
|
||||
# doit recevoir une heure de sortie, ne serait-ce que « maintenant ».
|
||||
if (cap > 0 and matin_nominal > 0) or "matin" in ecoule:
|
||||
nominal = min(matin_nominal, cap)
|
||||
sature = "matin" in ecoule and ecoule["matin"] >= nominal
|
||||
if "matin" in ecoule and not sature:
|
||||
restant_planif += ecoule["matin"]
|
||||
slots.append({"jour": jour, "half": "matin", "nominal": nominal, "debut": debut,
|
||||
"sature": sature})
|
||||
if not aprem_done:
|
||||
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else max(pause_dejeuner_fin_min, jour["retour_min"] or 0)
|
||||
cap = max(0, 23 * 60 + 59 - debut)
|
||||
@ -838,11 +852,17 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
# floor_room = marge disponible pour avancer le départ en cas d'avance,
|
||||
# sans descendre sous le plancher aprem_fin (présence minimale obligatoire).
|
||||
floor_room = max(0, (debut + aprem_nominal) - aprem_fin_min)
|
||||
sature = "aprem" in ecoule and ecoule["aprem"] >= aprem_nominal
|
||||
if "aprem" in ecoule and not sature:
|
||||
restant_planif += ecoule["aprem"]
|
||||
slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, "debut": debut,
|
||||
"cap": cap, "cap_pref": cap_pref, "floor_room": floor_room})
|
||||
"cap": cap, "cap_pref": cap_pref, "floor_room": floor_room,
|
||||
"sature": sature})
|
||||
|
||||
slots_restants = len(slots)
|
||||
ecart_total = (restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
|
||||
# Les créneaux saturés sortent du plan : leur sortie cible est « maintenant »,
|
||||
# ils ne peuvent rien rattraper ni écouler.
|
||||
ecart_total = (restant_planif - sum(s["nominal"] for s in slots if not s.get("sature"))) if slots else 0
|
||||
aprem_slots = [s for s in slots if s["half"] == "aprem"]
|
||||
adjs = [0] * len(aprem_slots)
|
||||
|
||||
@ -852,11 +872,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
# saturé se reporte sur les autres. En cas d'avance, on avance les départs symétriquement,
|
||||
# sans jamais descendre sous le plancher aprem_fin (une seule passe, pas de plafond souple).
|
||||
left = 0
|
||||
left_avance = 0
|
||||
if ecart_total > 0:
|
||||
left = ecart_total
|
||||
for cap_key in ("cap_pref", "cap"):
|
||||
while left > 0:
|
||||
open_idx = [i for i, s in enumerate(aprem_slots) if adjs[i] < s[cap_key] - s["nominal"]]
|
||||
open_idx = [i for i, s in enumerate(aprem_slots)
|
||||
if not s.get("sature") and adjs[i] < s[cap_key] - s["nominal"]]
|
||||
if not open_idx:
|
||||
break # tous les créneaux saturés à ce plafond : passe suivante, ou rattrapage impossible
|
||||
base, rem = divmod(left, len(open_idx))
|
||||
@ -867,7 +889,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
elif ecart_total < 0:
|
||||
left_avance = -ecart_total
|
||||
while left_avance > 0:
|
||||
open_idx = [i for i, s in enumerate(aprem_slots) if -adjs[i] < s["floor_room"]]
|
||||
open_idx = [i for i, s in enumerate(aprem_slots)
|
||||
if not s.get("sature") and -adjs[i] < s["floor_room"]]
|
||||
if not open_idx:
|
||||
break # tous les créneaux déjà au plancher : l'avance restante n'est pas plaçable
|
||||
base, rem = divmod(left_avance, len(open_idx))
|
||||
@ -875,7 +898,6 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0))
|
||||
adjs[i] -= take
|
||||
left_avance -= take
|
||||
|
||||
# Si les départs du soir ne suffisent pas à absorber le retard, deux leviers
|
||||
# supplémentaires dans l'ordre, bornés par les plages de présence : avancer
|
||||
# les arrivées du matin (jamais avant le début de « Présence matin »), puis
|
||||
@ -917,6 +939,38 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
gains, left = _repartir(left, [room for _, room in candidats])
|
||||
reprise_advs = {key: gain for (key, _), gain in zip(candidats, gains)}
|
||||
|
||||
# Miroir pour l'avance : si les départs du soir ne peuvent pas tout écouler
|
||||
# (plancher aprem_fin), retarder d'abord les arrivées du matin (jamais après
|
||||
# le début de « Présence matin »), puis les retours de midi (jamais après
|
||||
# le début de « Présence après-midi ») — la pause de midi s'allonge
|
||||
# d'autant. Une entrée déjà pointée n'est pas modifiable ; le jour en cours
|
||||
# ne vise pas une heure déjà passée.
|
||||
arrivee_delays: dict[int, int] = {} # id(slot matin) → minutes de retard à l'arrivée
|
||||
reprise_delays: dict[int, int] = {} # id(slot aprem) → minutes de retard au retour de midi
|
||||
if left_avance > 0:
|
||||
candidats = []
|
||||
for s in slots:
|
||||
if s["half"] != "matin" or s["jour"].get("matin_entree"):
|
||||
continue # arrivée déjà pointée : non modifiable
|
||||
base = max(s["debut"], now_min) if s["jour"]["date"] == today else s["debut"]
|
||||
room = max(0, matin_debut_min - base)
|
||||
if room:
|
||||
candidats.append((id(s), room))
|
||||
gains, left_avance = _repartir(left_avance, [room for _, room in candidats])
|
||||
arrivee_delays = {key: gain for (key, _), gain in zip(candidats, gains)}
|
||||
if left_avance > 0:
|
||||
candidats = []
|
||||
for s in aprem_slots:
|
||||
j = s["jour"]
|
||||
if j.get("aprem_entree"):
|
||||
continue # reprise déjà pointée : non modifiable
|
||||
base = max(s["debut"], now_min) if j["date"] == today else s["debut"]
|
||||
room = max(0, aprem_debut_min - base)
|
||||
if room:
|
||||
candidats.append((id(s), room))
|
||||
gains, left_avance = _repartir(left_avance, [room for _, room in candidats])
|
||||
reprise_delays = {key: gain for (key, _), gain in zip(candidats, gains)}
|
||||
|
||||
def _en_cours(jour, half: str) -> bool:
|
||||
"""True si cette demi-journée est déjà commencée aujourd'hui (entrée pointée,
|
||||
pas encore de sortie) : la cible ne peut alors pas retomber avant l'heure
|
||||
@ -939,7 +993,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
sortie_min = s["debut"] + s["nominal"]
|
||||
if _en_cours(s["jour"], "matin"):
|
||||
sortie_min = max(sortie_min, now_min)
|
||||
entree = minutes_to_time(s["debut"] - arrivee_advs.get(id(s), 0))
|
||||
entree = minutes_to_time(s["debut"] - arrivee_advs.get(id(s), 0) + arrivee_delays.get(id(s), 0))
|
||||
s["jour"]["cible_matin_entree"] = entree
|
||||
s["jour"]["cible_matin"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||||
|
||||
@ -947,7 +1001,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
sortie_min = max(s["debut"] + s["nominal"] + adjs[i], aprem_fin_min)
|
||||
if _en_cours(s["jour"], "aprem"):
|
||||
sortie_min = max(sortie_min, now_min)
|
||||
entree = minutes_to_time(s["debut"] - reprise_advs.get(id(s), 0))
|
||||
entree = minutes_to_time(s["debut"] - reprise_advs.get(id(s), 0) + reprise_delays.get(id(s), 0))
|
||||
s["jour"]["cible_aprem_entree"] = entree
|
||||
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||||
|
||||
|
||||
@ -61,7 +61,7 @@
|
||||
{% elif result.avance_min > 0 %}
|
||||
Reste à faire : <strong>{{ result.reste }}</strong>
|
||||
sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }}
|
||||
— avance à écouler : <strong>{{ result.avance }}</strong>, répartie sur les départs du soir pour finir la semaine à 0.
|
||||
— avance à écouler : <strong>{{ result.avance }}</strong>, répartie sur les départs du soir, puis arrivées et retours de midi, pour finir la semaine à 0.
|
||||
{% elif result.reste_min > 0 %}
|
||||
Reste à faire : <strong>{{ result.reste }}</strong>
|
||||
sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }}
|
||||
|
||||
@ -17,7 +17,10 @@
|
||||
est casé avant ton départ visé tant que c'est possible. En rattrapage extrême :
|
||||
départs du soir allongés en premier (jusqu'à minuit), puis arrivée du matin avancée
|
||||
(jamais avant le début de la présence matin), puis retour de midi avancé (jamais
|
||||
avant la fin de la présence matin). Propres à ton compte —
|
||||
avant la fin de la présence matin). En avance : départs du soir raccourcis
|
||||
(jamais sous la fin de la présence après-midi), puis arrivée du matin retardée
|
||||
(jamais après le début de la présence matin), puis retour de midi retardé
|
||||
(jamais après le début de la présence après-midi). Propres à ton compte —
|
||||
ne change rien pour les autres.
|
||||
</p>
|
||||
|
||||
|
||||
@ -505,3 +505,169 @@ def test_jour_en_cours_ne_vise_pas_une_heure_passee(freeze_today):
|
||||
assert vendredi["cible_matin_entree"] == "09:00" # pas d'arrivée rétroactive
|
||||
assert vendredi["cible_aprem_entree"] == "12:00" # borné par Présence matin, pas avant 10:30
|
||||
assert vendredi["cible_aprem"] == "23:59"
|
||||
|
||||
|
||||
# ── Avance : arrivées et retours de midi retardés en secours des départs ──────
|
||||
|
||||
def test_avance_ecoulee_sur_arrivee_puis_retour_de_midi(freeze_today):
|
||||
"""Jeudi fini à +48 avec une sortie nominale déjà au plancher aprem_fin
|
||||
(17:00) : l'avance ne tenait nulle part et les cibles restaient au rythme
|
||||
nominal. Elle s'écoule désormais en retardant l'arrivée du matin (08:30 →
|
||||
09:00, début de Présence matin) puis le retour de midi (13:30 → 13:48,
|
||||
sous le début de Présence après-midi 14:00)."""
|
||||
plages = {"arrivee_visee": "08:30"}
|
||||
pointages = [
|
||||
_pointage("2026-07-13", "08:30", "12:00", "13:30", "17:00"),
|
||||
_pointage("2026-07-14", "08:30", "12:00", "13:30", "17:00"),
|
||||
_pointage("2026-07-15", "08:30", "12:00", "13:30", "17:00"),
|
||||
_pointage("2026-07-16", "08:30", "12:00", "13:30", "17:48"), # +48
|
||||
_pointage("2026-07-17"),
|
||||
_pointage("2026-07-20"),
|
||||
]
|
||||
conges = [{"date": "2026-07-17", "type": "jour"}]
|
||||
result = compute_week(pointages, conges, 420, plages) # 7h00/jour : sortie nominale 17:00
|
||||
lundi = result["jours"][5]
|
||||
assert result["avance_min"] == 48
|
||||
assert lundi["cible_matin_entree"] == "09:00" # arrivée retardée jusqu'à Présence matin
|
||||
assert lundi["cible_matin"] == "12:00"
|
||||
assert lundi["cible_aprem_entree"] == "13:48" # retour de midi retardé de 18 min
|
||||
assert lundi["cible_aprem"] == "17:00" # déjà au plancher, rien à avancer
|
||||
|
||||
|
||||
def test_avance_par_defaut_passe_par_le_retour_de_midi(freeze_today):
|
||||
"""Plages par défaut (arrivée visée = début de Présence matin, rien à
|
||||
retarder côté arrivée) : l'avance restante après les départs du soir
|
||||
s'écoule sur le retour de midi, plafonné au début de Présence après-midi."""
|
||||
pointages = [
|
||||
_pointage("2026-07-13", "09:00", "12:00", "13:30", "17:30"),
|
||||
_pointage("2026-07-14", "09:00", "12:00", "13:30", "17:30"),
|
||||
_pointage("2026-07-15", "09:00", "12:00", "13:30", "17:30"),
|
||||
_pointage("2026-07-16", "09:00", "12:00", "13:30", "18:10"), # +40
|
||||
_pointage("2026-07-17"),
|
||||
_pointage("2026-07-20"),
|
||||
]
|
||||
conges = [{"date": "2026-07-17", "type": "jour"}]
|
||||
result = compute_week(pointages, conges, 420)
|
||||
lundi = result["jours"][5]
|
||||
assert result["avance_min"] == 40
|
||||
assert lundi["cible_matin_entree"] == "09:00" # rien à retarder (visée = Présence matin)
|
||||
assert lundi["cible_aprem_entree"] == "13:40" # retour de midi retardé de 10 min
|
||||
assert lundi["cible_aprem"] == "17:00" # départ réduit de 30 min puis plafonné
|
||||
|
||||
|
||||
# ── Demi-journée en cours saturée (temps écoulé ≥ nominal) ────────────────────
|
||||
|
||||
class _Freezer:
|
||||
"""Fige calcul.datetime à une date/heure donnée (2026-07-17 = vendredi)."""
|
||||
@staticmethod
|
||||
def freeze(monkeypatch, heure, minute):
|
||||
import calcul as calcul_module
|
||||
|
||||
class FrozenDatetime(calcul_module.datetime):
|
||||
_frozen = calcul_module.datetime(2026, 7, 17, heure, minute)
|
||||
|
||||
@classmethod
|
||||
def today(cls):
|
||||
return cls._frozen
|
||||
|
||||
monkeypatch.setattr(calcul_module, "datetime", FrozenDatetime)
|
||||
|
||||
|
||||
def test_en_cours_sature_matin_pas_de_double_comptage(monkeypatch):
|
||||
"""Régression : vendredi matin en cours depuis 09:00 sans sortie, il est
|
||||
13:45 (sortie de midi oubliée). Le temps écoulé (285 min) dépasse la
|
||||
capacité du créneau matin (180 min) : il était compté deux fois — une fois
|
||||
dans le travaillé, une fois réintégré au plan — si bien que suivre les
|
||||
cibles sur-livrait de 105 min. Désormais le créneau saturé propose
|
||||
« sortie maintenant », ne réintègre rien, et l'après-midi est allégé
|
||||
d'autant : suivi des cibles → solde 0 (à ~12 min près bornées par les
|
||||
plages : reprise ≥ maintenant, plancher 17:00)."""
|
||||
_Freezer.freeze(monkeypatch, 13, 45)
|
||||
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", matin_entree="09:00"),
|
||||
]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||
vendredi = result["jours"][4]
|
||||
# Sortie du matin = maintenant (285 min déjà faites, créneau saturé)
|
||||
assert vendredi["cible_matin"] == "13:45"
|
||||
# L'après-midi absorbe l'excédent du matin : départ au plancher 17:00
|
||||
# (nominal 288 − 78 de marge) et reprise retardée à maintenant.
|
||||
assert vendredi["cible_aprem_entree"] == "13:45"
|
||||
assert vendredi["cible_aprem"] == "17:00"
|
||||
# Suivre les cibles ne sur-livre plus de 105 min : reste ≤ 12 min
|
||||
# (le solde résiduel vient des bornes de plages, pas du calcul).
|
||||
suivis = [dict(p) for p in pointages]
|
||||
suivis[4] = _pointage("2026-07-17", "09:00", vendredi["cible_matin"],
|
||||
vendredi["cible_aprem_entree"], vendredi["cible_aprem"])
|
||||
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
|
||||
assert 0 <= apres["solde_min"] <= 12
|
||||
|
||||
|
||||
def test_en_cours_sature_aprem_propose_sortie_immediate(monkeypatch):
|
||||
"""Régression : vendredi après-midi en cours depuis 13:30 sans sortie, il est
|
||||
19:30 — le nominal aprem (234) est dépassé de 126 min. L'écart affichait un
|
||||
retard fantôme de 54 min ; désormais l'avance réelle (72 min) est montrée et
|
||||
la seule cible cohérente est « sortir maintenant »."""
|
||||
_Freezer.freeze(monkeypatch, 19, 30)
|
||||
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", aprem_entree="13:30"),
|
||||
]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||
vendredi = result["jours"][4]
|
||||
assert result["extra_min"] == 0 # plus de retard fantôme
|
||||
assert result["avance_min"] == 72 # l'excédent déjà travaillé, affiché tel quel
|
||||
assert vendredi["cible_aprem"] == "19:30" # sortir maintenant
|
||||
|
||||
|
||||
def test_en_cours_non_sature_reste_inchange(monkeypatch):
|
||||
"""Un créneau en cours classique (écoulé < nominal) garde le comportement
|
||||
historique : le temps écoulé est réintégré au plan, le rattrapage du
|
||||
retard pousse la sortie du soir au-delà du nominal."""
|
||||
_Freezer.freeze(monkeypatch, 14, 20)
|
||||
pointages = [
|
||||
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:24"), # -90
|
||||
_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", aprem_entree="13:30"),
|
||||
]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||
vendredi = result["jours"][4]
|
||||
assert result["extra_min"] == 144
|
||||
assert vendredi["cible_aprem"] == "19:48"
|
||||
suivis = [dict(p) for p in pointages]
|
||||
suivis[4] = _pointage("2026-07-17", "09:00", "12:00", "13:30", vendredi["cible_aprem"])
|
||||
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
|
||||
assert apres["solde_min"] == 0
|
||||
|
||||
|
||||
def test_entree_apres_fin_matin_le_surplus_ne_double_compte_pas(monkeypatch):
|
||||
"""Régression : entrée du matin pointée à 12:30 (après la fin de Présence
|
||||
matin), sans sortie, il est 13:45. Aucun créneau matin n'existe (capacité
|
||||
nulle) : le temps écoulé (75 min) ne doit pas être réintégré au plan, sinon
|
||||
l'après-midi du même jour se retrouve allongé de 75 min en trop. Le surplus
|
||||
matin (234) bascule sur l'après-midi, réduit de ce qui est déjà fait."""
|
||||
_Freezer.freeze(monkeypatch, 13, 45)
|
||||
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", matin_entree="12:30"),
|
||||
]
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||
vendredi = result["jours"][4]
|
||||
assert vendredi["cible_matin"] == "13:45" # sortie immédiate de l'entrée ouverte
|
||||
suivis = [dict(p) for p in pointages]
|
||||
suivis[4] = _pointage("2026-07-17", "12:30", vendredi["cible_matin"],
|
||||
vendredi["cible_aprem_entree"], vendredi["cible_aprem"])
|
||||
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
|
||||
assert apres["solde_min"] == 0
|
||||
|
||||
Reference in New Issue
Block a user