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}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user