Corrige le double comptage des demi-journées en cours et plafonne les cibles matin
Trois corrections sur les préconisations : - Une demi-journée en cours (entrée pointée sans sortie, aujourd'hui) était comptée deux fois — temps écoulé dans le travaillé ET nominal complet comme capacité restante — ce qui faisait disparaître le rattrapage pendant la journée et produisait des cibles en manque du temps déjà écoulé. Le temps écoulé est réintégré au besoin planifié. - La cible matin est plafonnée à la reprise de l'après-midi ; un créneau saturé reporte son surplus sur les créneaux encore ouverts (waterfill) au lieu d'afficher une sortie matin absurde. - Une semaine entièrement pointée mais en déficit affiche désormais un bandeau "Semaine terminée avec -X de manque" au lieu de rien.
This commit is contained in:
@ -1,5 +1,5 @@
|
|||||||
"""Business logic: compute worked hours and expected hours per day/week."""
|
"""Business logic: compute worked hours and expected hours per day/week."""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
|
|
||||||
DEFAULT_PLAGES = {
|
DEFAULT_PLAGES = {
|
||||||
"matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin
|
"matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin
|
||||||
@ -117,14 +117,30 @@ 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 (au lieu de tout reporter sur la fin de journée courante), pour permettre
|
# restantes, dans la limite de la capacité de chaque créneau (le matin est plafonné
|
||||||
# un rattrapage progressif et confortable jusqu'à la fin de semaine.
|
# à la reprise de l'après-midi, l'après-midi à minuit).
|
||||||
for jour in jours:
|
for jour in jours:
|
||||||
jour["cible_matin"] = None
|
jour["cible_matin"] = None
|
||||||
jour["cible_aprem"] = None
|
jour["cible_aprem"] = None
|
||||||
|
|
||||||
restant = total_du - total_travaille
|
restant = total_du - total_travaille
|
||||||
slots = [] # (jour, "matin"|"aprem", nominal_min)
|
|
||||||
|
# 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
|
||||||
|
if jour.get("matin_entree") and not jour.get("matin_sortie"):
|
||||||
|
restant_planif += max(0, now_min - hhmm_to_minutes(jour["matin_entree"]))
|
||||||
|
if jour.get("aprem_entree") and not jour.get("aprem_sortie"):
|
||||||
|
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
|
||||||
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
|
||||||
@ -138,33 +154,38 @@ 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:
|
||||||
slots.append((jour, "matin", matin_nominal))
|
debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else matin_debut_min
|
||||||
|
slots.append({"jour": jour, "half": "matin", "nominal": matin_nominal,
|
||||||
|
"debut": debut, "cap": max(0, pause_dejeuner_fin_min - debut)})
|
||||||
if not aprem_done:
|
if not aprem_done:
|
||||||
slots.append((jour, "aprem", aprem_nominal))
|
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
|
||||||
|
slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal,
|
||||||
|
"debut": debut, "cap": max(0, 23 * 60 + 59 - debut)})
|
||||||
|
|
||||||
extra_total = max(0, restant - sum(s[2] 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)
|
||||||
|
|
||||||
if slots_restants:
|
# Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau :
|
||||||
base, remainder = divmod(extra_total, slots_restants)
|
# le surplus d'un créneau saturé se reporte sur les autres.
|
||||||
for i, (jour, half, nominal) in enumerate(slots):
|
adjs = [0] * slots_restants
|
||||||
adj = base + (1 if i < remainder else 0)
|
left = extra_total
|
||||||
duree_cible = nominal + adj
|
while left > 0:
|
||||||
if adj <= 0:
|
open_idx = [i for i, s in enumerate(slots) if adjs[i] < s["cap"] - s["nominal"]]
|
||||||
continue # rythme normal suffit pour cette demi-journée, rien à signaler
|
if not open_idx:
|
||||||
if half == "matin":
|
break # tous les créneaux saturés : rattrapage partiellement impossible cette semaine
|
||||||
debut_min = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else matin_debut_min
|
base, rem = divmod(left, len(open_idx))
|
||||||
sortie_min = max(debut_min + duree_cible, matin_fin_min)
|
for k, i in enumerate(open_idx):
|
||||||
else:
|
give = min(slots[i]["cap"] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
|
||||||
debut_min = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
|
adjs[i] += give
|
||||||
sortie_min = max(debut_min + duree_cible, aprem_fin_min)
|
left -= give
|
||||||
if sortie_min > 23 * 60 + 59:
|
|
||||||
continue # rattrapage impossible en une seule demi-journée
|
for i, s in enumerate(slots):
|
||||||
cible = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
if adjs[i] <= 0:
|
||||||
if half == "matin":
|
continue # rythme normal suffit pour cette demi-journée, rien à signaler
|
||||||
jour["cible_matin"] = cible
|
sortie_min = s["debut"] + s["nominal"] + adjs[i]
|
||||||
else:
|
sortie_min = max(sortie_min, matin_fin_min if s["half"] == "matin" else aprem_fin_min)
|
||||||
jour["cible_aprem"] = cible
|
cible = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||||||
|
s["jour"]["cible_matin" if s["half"] == "matin" else "cible_aprem"] = cible
|
||||||
|
|
||||||
reste_min = max(0, restant)
|
reste_min = max(0, restant)
|
||||||
|
|
||||||
|
|||||||
@ -29,5 +29,10 @@
|
|||||||
Vous êtes à jour ou en avance — rien à rattraper cette semaine.
|
Vous êtes à jour ou en avance — rien à rattraper cette semaine.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% elif result.solde_min < 0 %}
|
||||||
|
<div class="reste-info">
|
||||||
|
Semaine terminée avec <strong class="reste-extra">{{ result.solde }}</strong> de manque —
|
||||||
|
complétez ou corrigez les saisies, ou rattrapez sur une autre semaine.
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -174,6 +174,75 @@ def test_compute_week_jour_courant_non_entame_rythme_normal_suffit(freeze_today)
|
|||||||
assert vendredi["cible_aprem"] is None
|
assert vendredi["cible_aprem"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today):
|
||||||
|
"""Régression : une demi-journée en cours (entrée pointée sans sortie, aujourd'hui)
|
||||||
|
était comptée deux fois — son temps écoulé dans le travaillé ET son nominal complet
|
||||||
|
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é à
|
||||||
|
10:30) matin en cours depuis 09:00. Les 60 min doivent être réparties : le matin
|
||||||
|
est plafonné par la pause déjeuner (max +36), donc 30/30 passe sans saturation.
|
||||||
|
"""
|
||||||
|
pointages = [
|
||||||
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:54"), # -60 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", matin_entree="09:00"), # en cours, 90 min écoulées
|
||||||
|
]
|
||||||
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||||
|
vendredi = result["jours"][4]
|
||||||
|
assert result["extra_min"] == 60
|
||||||
|
# matin : 09:00 + 234 nominal + 30 = 13:24 ; aprem : 13:30 + 234 + 30 = 17:54
|
||||||
|
assert vendredi["cible_matin"] == "13:24"
|
||||||
|
assert vendredi["cible_aprem"] == "17:54"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_cible_matin_plafonnee_a_la_pause_dejeuner(freeze_today):
|
||||||
|
"""La cible matin ne dépasse jamais la reprise de l'après-midi : un créneau
|
||||||
|
saturé reçoit sa capacité max, le surplus reste non alloué (pas de cible absurde).
|
||||||
|
|
||||||
|
Vendredi en congé après-midi : seule sa matinée reste (nominal 234, capacité
|
||||||
|
09:00→13:30 = 270, soit +36 max). Avec 120 min de retard, la cible est plafonnée
|
||||||
|
à 13:30 au lieu d'un impossible 14:54.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
vendredi = result["jours"][4]
|
||||||
|
assert result["extra_min"] == 120
|
||||||
|
assert vendredi["cible_matin"] == "13:30"
|
||||||
|
assert vendredi["cible_aprem"] is None # congé
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
plafonne à +36 — les 4 min restantes basculent sur l'après-midi (+44).
|
||||||
|
"""
|
||||||
|
pointages = [
|
||||||
|
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:34"), # -80 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 result["extra_min"] == 80
|
||||||
|
# matin saturé : 09:00 + 270 = 13:30 ; aprem : 13:30 + 234 + 44 = 18:08
|
||||||
|
assert vendredi["cible_matin"] == "13:30"
|
||||||
|
assert vendredi["cible_aprem"] == "18:08"
|
||||||
|
|
||||||
|
|
||||||
def test_compute_week_plages_personnalisees(freeze_today):
|
def test_compute_week_plages_personnalisees(freeze_today):
|
||||||
"""Les seuils de plage sont bien paramétrables, pas juste figés sur les défauts."""
|
"""Les seuils de plage sont bien paramétrables, pas juste figés sur les défauts."""
|
||||||
plages = {
|
plages = {
|
||||||
|
|||||||
Reference in New Issue
Block a user