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:
36
README.md
36
README.md
@ -33,17 +33,33 @@ La semaine cible est **39h** (7h48 / jour). Les directives imposent des plages d
|
|||||||
Ces horaires sont réglables par utilisateur depuis la page **Réglages** de l'app (voir
|
Ces horaires sont réglables par utilisateur depuis la page **Réglages** de l'app (voir
|
||||||
[Configuration](#configuration)).
|
[Configuration](#configuration)).
|
||||||
|
|
||||||
Le matin n'est **jamais** touché : sa durée nominale est fixe, et si elle ne rentre pas
|
Le moteur de préconisations vise un **solde à 0 en fin de semaine** : l'écart entre
|
||||||
avant la pause déjeuner, le surplus est reporté sur l'après-midi du même jour. Quand un
|
heures dues et heures déjà travaillées (temps écoulé des demi-journées en cours déduit)
|
||||||
écart hebdomadaire apparaît, il est **réparti uniquement sur les heures de départ du
|
est réparti à parts égales sur les demi-journées restantes. Les règles, dans l'ordre :
|
||||||
soir** des demi-journées restantes (répartition égale, recalculée à chaque fois qu'un
|
|
||||||
départ atteint sa limite) :
|
|
||||||
- un **retard** repousse les départs — d'abord dans la marge de confort `depart_vise`,
|
|
||||||
puis au-delà si besoin (jusqu'à minuit) ;
|
|
||||||
- une **avance** avance les départs, mais jamais en dessous du minimum de présence de
|
|
||||||
l'après-midi.
|
|
||||||
|
|
||||||
Si le rythme normal suffit déjà à rentrer dans les clous, aucune préconisation n'est affichée.
|
1. **Créneaux** : chaque demi-journée non pointée (entrée + sortie) et non congéfiée
|
||||||
|
reste à planifier, y compris les jours passés — la saisie après coup est admise.
|
||||||
|
2. **Le matin n'est jamais ajusté** par l'écart hebdomadaire : arrivée à l'heure visée
|
||||||
|
(`arrivee_visee`, au plus tôt l'heure de retour après repos d'astreinte), sortie au
|
||||||
|
nominal plafonné à la pause de midi. 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).
|
||||||
|
3. **Retard** — trois leviers en cascade : repousser les **départs du soir** (d'abord
|
||||||
|
dans la marge du départ visé `depart_vise`, puis jusqu'à minuit) ; sinon avancer
|
||||||
|
les **arrivées du matin** (jamais avant le début de présence matin) ; sinon avancer
|
||||||
|
les **retours de midi** (jamais avant la fin de présence matin, ni avant la sortie
|
||||||
|
du matin pointée, ni avant l'heure qu'il est).
|
||||||
|
4. **Avance** — cascade miroir : avancer les **départs du soir** (jamais sous la fin de
|
||||||
|
présence après-midi) ; sinon retarder les **arrivées du matin** (jamais après le
|
||||||
|
début de présence matin) ; sinon retarder les **retours de midi** (jamais après le
|
||||||
|
début de présence après-midi — la pause s'allonge d'autant).
|
||||||
|
5. **Journée en cours** : une demi-journée commencée (entrée pointée sans sortie) se
|
||||||
|
planifie depuis son heure d'entrée, son temps déjà écoulé est réintégré au plan ;
|
||||||
|
s'il atteint ou dépasse le nominal du créneau (**créneau saturé**, ex. sortie de
|
||||||
|
midi oubliée), la cible devient « sortir maintenant » et l'écart se reporte sur les
|
||||||
|
autres créneaux — jamais de double comptage du temps déjà fait.
|
||||||
|
6. Une entrée déjà pointée n'est jamais déplacée, et aucune cible du jour courant ne
|
||||||
|
recommande une heure déjà passée. Ce qui ne tient dans aucune plage reste visible
|
||||||
|
dans les compteurs « reste à faire » / avance.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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
|
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_debut_min = hhmm_to_minutes(plages["matin_debut"])
|
||||||
matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
|
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"])
|
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
|
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
|
# 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
|
# 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 »).
|
# 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
|
# En cas d'avance, cascade miroir : départs du soir avancés (jamais sous la
|
||||||
# descendre sous le plancher aprem_fin (présence minimale obligatoire).
|
# 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.
|
# Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi.
|
||||||
for jour in jours:
|
for jour in jours:
|
||||||
jour["cible_matin"] = None
|
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
|
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")
|
today = datetime.today().strftime("%Y-%m-%d")
|
||||||
now_min = datetime.today().hour * 60 + datetime.today().minute
|
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
|
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:
|
for jour in jours:
|
||||||
# Un jour passé non renseigné n'est pas exclu : cette appli permet de saisir
|
# 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
|
# 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à.
|
# (retour au-delà de minuit suivant) : rien à planifier ce jour-là.
|
||||||
if (jour.get("retour_min") or 0) >= 24 * 60:
|
if (jour.get("retour_min") or 0) >= 24 * 60:
|
||||||
continue
|
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"]
|
matin_nominal = jour["matin_nominal_min"]
|
||||||
aprem_nominal = jour["aprem_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
|
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
|
aprem_nominal += surplus
|
||||||
# Le matin n'est jamais ajusté par le rattrapage hebdomadaire : seule sa
|
# 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.
|
# capacité avant la pause de midi compte, pour le calcul du surplus ci-dessus.
|
||||||
if cap > 0 and matin_nominal > 0:
|
# Une demi-journée en cours garde toujours son créneau : l'entrée ouverte
|
||||||
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
|
# 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:
|
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)
|
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)
|
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,
|
# floor_room = marge disponible pour avancer le départ en cas d'avance,
|
||||||
# sans descendre sous le plancher aprem_fin (présence minimale obligatoire).
|
# sans descendre sous le plancher aprem_fin (présence minimale obligatoire).
|
||||||
floor_room = max(0, (debut + aprem_nominal) - aprem_fin_min)
|
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,
|
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)
|
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"]
|
aprem_slots = [s for s in slots if s["half"] == "aprem"]
|
||||||
adjs = [0] * len(aprem_slots)
|
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,
|
# 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).
|
# sans jamais descendre sous le plancher aprem_fin (une seule passe, pas de plafond souple).
|
||||||
left = 0
|
left = 0
|
||||||
|
left_avance = 0
|
||||||
if ecart_total > 0:
|
if ecart_total > 0:
|
||||||
left = ecart_total
|
left = ecart_total
|
||||||
for cap_key in ("cap_pref", "cap"):
|
for cap_key in ("cap_pref", "cap"):
|
||||||
while left > 0:
|
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:
|
if not open_idx:
|
||||||
break # tous les créneaux saturés à ce plafond : passe suivante, ou rattrapage impossible
|
break # tous les créneaux saturés à ce plafond : passe suivante, ou rattrapage impossible
|
||||||
base, rem = divmod(left, len(open_idx))
|
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:
|
elif ecart_total < 0:
|
||||||
left_avance = -ecart_total
|
left_avance = -ecart_total
|
||||||
while left_avance > 0:
|
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:
|
if not open_idx:
|
||||||
break # tous les créneaux déjà au plancher : l'avance restante n'est pas plaçable
|
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))
|
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))
|
take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0))
|
||||||
adjs[i] -= take
|
adjs[i] -= take
|
||||||
left_avance -= take
|
left_avance -= take
|
||||||
|
|
||||||
# Si les départs du soir ne suffisent pas à absorber le retard, deux leviers
|
# 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
|
# 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
|
# 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])
|
gains, left = _repartir(left, [room for _, room in candidats])
|
||||||
reprise_advs = {key: gain for (key, _), gain in zip(candidats, gains)}
|
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:
|
def _en_cours(jour, half: str) -> bool:
|
||||||
"""True si cette demi-journée est déjà commencée aujourd'hui (entrée pointée,
|
"""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
|
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"]
|
sortie_min = s["debut"] + s["nominal"]
|
||||||
if _en_cours(s["jour"], "matin"):
|
if _en_cours(s["jour"], "matin"):
|
||||||
sortie_min = max(sortie_min, now_min)
|
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_entree"] = entree
|
||||||
s["jour"]["cible_matin"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
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)
|
sortie_min = max(s["debut"] + s["nominal"] + adjs[i], aprem_fin_min)
|
||||||
if _en_cours(s["jour"], "aprem"):
|
if _en_cours(s["jour"], "aprem"):
|
||||||
sortie_min = max(sortie_min, now_min)
|
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_entree"] = entree
|
||||||
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||||||
|
|
||||||
|
|||||||
@ -61,7 +61,7 @@
|
|||||||
{% elif result.avance_min > 0 %}
|
{% elif result.avance_min > 0 %}
|
||||||
Reste à faire : <strong>{{ result.reste }}</strong>
|
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 '' }}
|
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 %}
|
{% elif result.reste_min > 0 %}
|
||||||
Reste à faire : <strong>{{ result.reste }}</strong>
|
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 '' }}
|
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 :
|
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
|
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
|
(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.
|
ne change rien pour les autres.
|
||||||
</p>
|
</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_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_entree"] == "12:00" # borné par Présence matin, pas avant 10:30
|
||||||
assert vendredi["cible_aprem"] == "23:59"
|
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