Répartit avance/retard sur les départs du soir, plus jamais sur le matin
Le rattrapage hebdomadaire ne portait que sur le retard et pouvait s'étaler sur le matin quand il avait de la marge. Objectif demandé : pouvoir planifier les horaires à l'avance pour arriver à 0 en fin de semaine, en ajustant uniquement les heures de départ du soir - dans un sens (retard) comme dans l'autre (avance, plafonnée par aprem_fin). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -120,14 +120,14 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
|||||||
cumul_chain_ok = False
|
cumul_chain_ok = False
|
||||||
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 l'écart restant (retard à rattraper, ou avance à
|
||||||
# restantes, dans la limite de la capacité de chaque créneau. La pause de midi
|
# écouler) à parts égales sur les heures de départ du soir des demi-journées
|
||||||
# (matin_fin → reprise, 12:00 → 13:30 par défaut) est intouchable : la cible
|
# restantes, pour viser un solde à 0 en fin de semaine. Le matin n'est jamais
|
||||||
# matin ne dépasse jamais matin_fin, et le nominal matin qui n'y tient pas
|
# ajusté : la pause de midi (matin_fin → reprise, 12:00 → 13:30 par défaut) est
|
||||||
# bascule sur l'après-midi du même jour. Le matin démarre à l'arrivée visée
|
# intouchable, et le nominal matin qui n'y tient pas bascule sur l'après-midi du
|
||||||
# (sinon matin_debut) ; l'après-midi vise le départ du soir configuré (plafond
|
# même jour. L'après-midi vise le départ du soir configuré (plafond souple :
|
||||||
# souple : dépassé seulement si le rattrapage ne tient nulle part avant), avec
|
# dépassé seulement si le rattrapage ne tient nulle part avant), avec minuit
|
||||||
# minuit comme limite dure.
|
# comme limite dure en cas de retard, ou aprem_fin comme plancher en cas d'avance.
|
||||||
for jour in jours:
|
for jour in jours:
|
||||||
jour["cible_matin"] = None
|
jour["cible_matin"] = None
|
||||||
jour["cible_aprem"] = None
|
jour["cible_aprem"] = None
|
||||||
@ -149,7 +149,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
|||||||
if jour.get("aprem_entree") and not jour.get("aprem_sortie"):
|
if jour.get("aprem_entree") and not jour.get("aprem_sortie"):
|
||||||
restant_planif += max(0, now_min - hhmm_to_minutes(jour["aprem_entree"]))
|
restant_planif += max(0, now_min - hhmm_to_minutes(jour["aprem_entree"]))
|
||||||
|
|
||||||
slots = [] # {jour, half, nominal, debut, cap} — cap = durée maximale du créneau
|
slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage
|
||||||
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
|
||||||
@ -171,42 +171,62 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
|||||||
surplus = max(0, matin_nominal - cap)
|
surplus = max(0, matin_nominal - cap)
|
||||||
if surplus and not aprem_done:
|
if surplus and not aprem_done:
|
||||||
aprem_nominal += surplus
|
aprem_nominal += surplus
|
||||||
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap),
|
# Le matin n'est jamais ajusté par le rattrapage hebdomadaire : seule sa
|
||||||
"debut": debut, "cap": cap, "cap_pref": cap})
|
# capacité avant la pause de midi compte, pour le calcul du surplus ci-dessus.
|
||||||
|
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
|
||||||
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 = max(0, 23 * 60 + 59 - debut)
|
||||||
cap_pref = min(cap, max(0, depart_vise_min - debut)) if depart_vise_min else cap
|
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,
|
# floor_room = marge disponible pour avancer le départ en cas d'avance,
|
||||||
"debut": debut, "cap": cap, "cap_pref": cap_pref})
|
# sans descendre sous le plancher aprem_fin (présence minimale obligatoire).
|
||||||
|
floor_room = max(0, (debut + aprem_nominal) - aprem_fin_min)
|
||||||
|
slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, "debut": debut,
|
||||||
|
"cap": cap, "cap_pref": cap_pref, "floor_room": floor_room})
|
||||||
|
|
||||||
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)
|
||||||
|
ecart_total = (restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
|
||||||
|
aprem_slots = [s for s in slots if s["half"] == "aprem"]
|
||||||
|
adjs = [0] * len(aprem_slots)
|
||||||
|
|
||||||
# Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau,
|
# Répartition équitable de l'écart (retard ou avance), plafonnée par la capacité de
|
||||||
# en deux passes : d'abord dans le cadre visé (avant le départ du soir), puis
|
# chaque créneau du soir, en deux passes en cas de retard : d'abord dans le cadre visé
|
||||||
# au-delà si ça ne tient pas. Le surplus d'un créneau saturé se reporte sur les autres.
|
# (avant le départ du soir), puis au-delà si ça ne tient pas. Le surplus d'un créneau
|
||||||
adjs = [0] * slots_restants
|
# saturé se reporte sur les autres. En cas d'avance, on avance les départs symétriquement,
|
||||||
left = extra_total
|
# sans jamais descendre sous le plancher aprem_fin (une seule passe, pas de plafond souple).
|
||||||
|
if ecart_total > 0:
|
||||||
|
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(slots) if adjs[i] < s[cap_key] - s["nominal"]]
|
open_idx = [i for i, s in enumerate(aprem_slots) if 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))
|
||||||
for k, i in enumerate(open_idx):
|
for k, i in enumerate(open_idx):
|
||||||
give = min(slots[i][cap_key] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
|
give = min(aprem_slots[i][cap_key] - aprem_slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
|
||||||
adjs[i] += give
|
adjs[i] += give
|
||||||
left -= give
|
left -= give
|
||||||
|
elif ecart_total < 0:
|
||||||
|
left = -ecart_total
|
||||||
|
while left > 0:
|
||||||
|
open_idx = [i for i, s in enumerate(aprem_slots) if -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, len(open_idx))
|
||||||
|
for k, i in enumerate(open_idx):
|
||||||
|
take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0))
|
||||||
|
adjs[i] -= take
|
||||||
|
left -= take
|
||||||
|
|
||||||
for i, s in enumerate(slots):
|
for i, s in enumerate(aprem_slots):
|
||||||
if adjs[i] <= 0:
|
if adjs[i] == 0:
|
||||||
continue # rythme normal suffit pour cette demi-journée, rien à signaler
|
continue # rythme normal suffit pour cette demi-journée, rien à signaler
|
||||||
sortie_min = s["debut"] + s["nominal"] + adjs[i]
|
sortie_min = s["debut"] + s["nominal"] + adjs[i]
|
||||||
sortie_min = max(sortie_min, matin_fin_min if s["half"] == "matin" else aprem_fin_min)
|
sortie_min = max(sortie_min, aprem_fin_min)
|
||||||
cible = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||||||
s["jour"]["cible_matin" if s["half"] == "matin" else "cible_aprem"] = cible
|
|
||||||
|
|
||||||
|
extra_total = max(0, ecart_total)
|
||||||
|
avance_total = max(0, -ecart_total)
|
||||||
reste_min = max(0, restant)
|
reste_min = max(0, restant)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -219,6 +239,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
|||||||
"reste": minutes_to_hhmm(reste_min),
|
"reste": minutes_to_hhmm(reste_min),
|
||||||
"extra_min": extra_total,
|
"extra_min": extra_total,
|
||||||
"extra": minutes_to_hhmm(extra_total),
|
"extra": minutes_to_hhmm(extra_total),
|
||||||
|
"avance_min": avance_total,
|
||||||
|
"avance": minutes_to_hhmm(avance_total),
|
||||||
"slots_restants": slots_restants,
|
"slots_restants": slots_restants,
|
||||||
"moyenne_extra_min": (extra_total // slots_restants) if slots_restants else 0,
|
"moyenne_extra_min": (extra_total // slots_restants) if slots_restants else 0,
|
||||||
"moyenne_extra": minutes_to_hhmm((extra_total // slots_restants) if slots_restants else 0),
|
"moyenne_extra": minutes_to_hhmm((extra_total // slots_restants) if slots_restants else 0),
|
||||||
|
|||||||
@ -21,6 +21,10 @@
|
|||||||
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 '' }}
|
||||||
— à rattraper : <strong class="reste-extra">+{{ result.extra }}</strong> (~+{{ result.moyenne_extra }} chacune)
|
— à rattraper : <strong class="reste-extra">+{{ result.extra }}</strong> (~+{{ result.moyenne_extra }} chacune)
|
||||||
|
{% 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.
|
||||||
{% 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 '' }}
|
||||||
|
|||||||
@ -248,14 +248,63 @@ def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today
|
|||||||
assert vendredi["cible_aprem"] == "19:38"
|
assert vendredi["cible_aprem"] == "19:38"
|
||||||
|
|
||||||
|
|
||||||
def test_compute_week_cible_matin_possible_quand_la_plage_matin_a_de_la_marge(freeze_today):
|
def test_compute_week_avance_reduit_les_departs_du_soir_restants(freeze_today):
|
||||||
"""Avec des plages personnalisées où la matinée obligatoire est plus longue que
|
"""Symétrique du rattrapage : une avance (travaillé plus que dû) doit réduire
|
||||||
le nominal, une cible matin peut être proposée — mais jamais au-delà de
|
les heures de départ du soir des jours restants, pas seulement rester
|
||||||
matin_fin, pour que la pause (matin_fin → reprise) reste libre.
|
invisible — pour permettre de planifier un solde à 0 en fin de semaine.
|
||||||
|
|
||||||
|
Plages personnalisées sans report matin→aprem (capacité matin 240 = nominal).
|
||||||
|
Lundi +20 min d'avance, reste de la semaine vide : les 4 départs restants
|
||||||
|
sont chacun avancés de 5 min (18:00 nominal → 17:55).
|
||||||
|
"""
|
||||||
|
plages = {
|
||||||
|
"matin_debut": "09:00", "matin_fin": "13:00",
|
||||||
|
"aprem_debut": "14:00", "aprem_fin": "17:00",
|
||||||
|
"pause_dejeuner_fin": "14:00",
|
||||||
|
}
|
||||||
|
heures_jour = 480 # 240 matin + 240 aprem
|
||||||
|
pointages = [
|
||||||
|
_pointage("2026-07-13", "09:00", "13:00", "14:00", "18:20"), # +20 min
|
||||||
|
_pointage("2026-07-14"),
|
||||||
|
_pointage("2026-07-15"),
|
||||||
|
_pointage("2026-07-16"),
|
||||||
|
_pointage("2026-07-17"),
|
||||||
|
]
|
||||||
|
result = compute_week(pointages, [], heures_jour, plages)
|
||||||
|
assert result["extra_min"] == 0 # extra_min ne compte que le retard, pas l'avance
|
||||||
|
for jour in result["jours"][1:]:
|
||||||
|
assert jour["cible_matin"] is None
|
||||||
|
assert jour["cible_aprem"] == "17:55"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_avance_ne_descend_jamais_sous_aprem_fin(freeze_today):
|
||||||
|
"""L'avance ne peut pas faire descendre un départ sous le plancher aprem_fin
|
||||||
|
(présence minimale obligatoire) : au-delà de la marge disponible, l'avance
|
||||||
|
restante n'est simplement pas placée (elle reste visible dans le solde).
|
||||||
|
|
||||||
|
234 min nominal aprem (13:30 + 234 = 17:24), plancher 17:00 : seulement 24 min
|
||||||
|
de marge par jour. Avec 200 min d'avance sur un seul jour ouvert, la cible
|
||||||
|
plafonne à 17:00 au lieu de descendre à 17:24 - 200.
|
||||||
|
"""
|
||||||
|
pointages = [
|
||||||
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "20:34"), # +200 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, [], HEURES_JOUR_MIN)
|
||||||
|
vendredi = result["jours"][4]
|
||||||
|
assert vendredi["cible_aprem"] == "17:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_cible_matin_jamais_ajustee_meme_avec_de_la_marge(freeze_today):
|
||||||
|
"""Le rattrapage hebdomadaire ne porte jamais sur le matin, même quand la
|
||||||
|
matinée obligatoire est plus longue que le nominal et aurait de la marge.
|
||||||
|
|
||||||
Matin 08:00→13:00 (capacité 300 > nominal 270). Vendredi en congé après-midi,
|
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
|
120 min de retard : le seul créneau ouvert (le matin) n'est pas ajustable, le
|
||||||
pas plaçable. La pause 13:00→14:00 reste intacte.
|
retard n'est pas plaçable ailleurs (l'après-midi est en congé).
|
||||||
"""
|
"""
|
||||||
plages = {
|
plages = {
|
||||||
"matin_debut": "08:00", "matin_fin": "13:00",
|
"matin_debut": "08:00", "matin_fin": "13:00",
|
||||||
@ -274,15 +323,14 @@ def test_compute_week_cible_matin_possible_quand_la_plage_matin_a_de_la_marge(fr
|
|||||||
result = compute_week(pointages, conges, heures_jour, plages)
|
result = compute_week(pointages, conges, heures_jour, plages)
|
||||||
vendredi = result["jours"][4]
|
vendredi = result["jours"][4]
|
||||||
assert result["extra_min"] == 120
|
assert result["extra_min"] == 120
|
||||||
# 08:00 + 270 nominal + 30 (saturé au plafond matin_fin) = 13:00
|
assert vendredi["cible_matin"] is None
|
||||||
assert vendredi["cible_matin"] == "13:00"
|
|
||||||
assert vendredi["cible_aprem"] is None # congé
|
assert vendredi["cible_aprem"] is None # congé
|
||||||
|
|
||||||
|
|
||||||
def test_compute_week_arrivee_visee_etend_le_creneau_matin(freeze_today):
|
def test_compute_week_arrivee_visee_n_etend_pas_le_rattrapage_au_matin(freeze_today):
|
||||||
"""L'arrivée visée sert de début projeté du matin : en visant 08:00, le créneau
|
"""L'arrivée visée sert de début projeté du matin (pour le calcul du surplus
|
||||||
matin gagne de la capacité (08:00→12:00 = 240 ≥ 234 nominal) et peut recevoir
|
basculant vers l'après-midi), mais n'ouvre jamais le matin au rattrapage
|
||||||
du rattrapage — toujours sans dépasser matin_fin.
|
hebdomadaire — celui-ci reste réservé aux départs du soir.
|
||||||
"""
|
"""
|
||||||
plages = {"arrivee_visee": "08:00"}
|
plages = {"arrivee_visee": "08:00"}
|
||||||
conges = [{"date": "2026-07-17", "type": "aprem"}]
|
conges = [{"date": "2026-07-17", "type": "aprem"}]
|
||||||
@ -296,8 +344,7 @@ def test_compute_week_arrivee_visee_etend_le_creneau_matin(freeze_today):
|
|||||||
result = compute_week(pointages, conges, HEURES_JOUR_MIN, plages)
|
result = compute_week(pointages, conges, HEURES_JOUR_MIN, plages)
|
||||||
vendredi = result["jours"][4]
|
vendredi = result["jours"][4]
|
||||||
assert result["extra_min"] == 120
|
assert result["extra_min"] == 120
|
||||||
# 08:00 + 234 nominal + 6 (saturé au plafond matin_fin) = 12:00
|
assert vendredi["cible_matin"] is None
|
||||||
assert vendredi["cible_matin"] == "12:00"
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_week_depart_vise_plafond_souple(freeze_today):
|
def test_compute_week_depart_vise_plafond_souple(freeze_today):
|
||||||
|
|||||||
Reference in New Issue
Block a user