Ajoute les jours fériés, l'astreinte week-end 0h-0h et les ratios éditables

- Bouton F : marque un jour férié (dû 0, sans consommer de congé) ; il
  qualifie les règles « ferie » et étend la fenêtre d'astreinte du jour.
- Samedi, dimanche et fériés : l'astreinte compte de 0h00 à 0h00 ; un jour
  AS déclaré garde sa borne du lendemain 18h pour ne pas tronquer les
  interventions nocturnes.
- Ratios d'astreinte éditables à la main dans Réglages (nom, jours, fenêtre,
  ratio), avec ajout/suppression de règles et validation.
- Un quart d'heure entamé par deux interventions qui se suivent n'est compté
  qu'une fois : les lignes d'événements somment au total du jour.
- Décompte mieux expliqué dans l'interface : quarts d'horloge (:00/:15/:30/:45),
  fenêtre couverte par événement, exemple chiffré.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-08 23:30:15 +02:00
parent 23271d736d
commit dc80e81db2
11 changed files with 599 additions and 111 deletions

View File

@ -219,7 +219,7 @@ def test_decompte_vendredi_vers_samedi_reste_en_nuit():
def test_decompte_chevauchement_dedouble():
"""10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent
qu'une fois dans le total du jour, chaque event garde son propre détail."""
qu'une fois, attribuées au premier événement ; les lignes somment au total."""
d = decompte_astreinte("2026-07-13", [
{"debut": "10:00", "fin": "10:20"},
{"debut": "10:10", "fin": "10:30"},
@ -227,13 +227,29 @@ def test_decompte_chevauchement_dedouble():
assert len(d["events"]) == 2
assert d["events"][0]["tranches"] == 2 # tranches 10:00 + 10:15
assert d["events"][0]["min"] == 30
assert d["events"][1]["tranches"] == 2 # 10:10 aligné sur la tranche 10:00
assert d["events"][1]["min"] == 30
assert d["events"][1]["tranches"] == 0 # les deux tranches déjà comptées
assert d["events"][1]["min"] == 0
assert d["tranches"] == 2 # union dédoublonnée
assert d["total_min"] == 30
assert sum(e["min"] for e in d["events"]) == d["total_min"]
assert d["categories"][0]["nom"] == "Journée"
def test_decompte_quart_partage_entre_interventions():
"""Deux interventions qui se suivent et finissent/débutent dans le même quart :
le quart n'est compté qu'une fois (attribué à la première), les lignes
d'événements somment au total du jour."""
d = decompte_astreinte("2026-07-13", [
{"debut": "22:39", "fin": "23:05"},
{"debut": "23:02", "fin": "23:49"},
])
assert d["events"][0]["tranches"] == 3 # 22:30 + 22:45 + 23:00
assert d["events"][1]["tranches"] == 3 # 23:15 + 23:30 + 23:45 (23:00 déjà compté)
assert sum(e["tranches"] for e in d["events"]) == d["tranches"] == 6
assert d["total_min"] == 90
assert d["total_pondere_min"] == 135 # 6 × 15 × 1,5 (Nuit)
def test_decompte_event_incomplet_ignore():
"""Un événement sans debut ou sans fin est ignoré."""
d = decompte_astreinte("2026-07-13", [{"debut": "22:00"}, {"fin": "23:00"}, {}])
@ -426,11 +442,21 @@ def test_load_facteurs_astreinte_defaut_si_absent():
def test_fenetre_astreinte_jour():
"""Le jour déclaré couvre 18:00 → 18:00 le lendemain ; le lendemain n'est
couvert que jusqu'à 18:00 ; deux astreintes consécutives se recollent."""
assert fenetre_astreinte_jour("2026-07-14", ["2026-07-14"]) == (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14"]) == (0, ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14", "2026-07-15"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-14"]) is None
couvert que jusqu'à 18:00 ; deux astreintes consécutives se recollent.
Samedi, dimanche et férié : journée entière 0h00 → 0h00."""
# jours ouvrés non fériés (mercredi 15 → vendredi 17 juillet 2026)
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-15"]) == (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-15"]) == (0, ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-16", ["2026-07-15", "2026-07-16"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-17", ["2026-07-15"]) is None
# samedi et dimanche : journée entière dès 0h00, déclarés ou lendemain
# d'astreinte (le jour AS déclaré garde sa borne du lendemain 18:00)
assert fenetre_astreinte_jour("2026-07-18", ["2026-07-18"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN)
assert fenetre_astreinte_jour("2026-07-19", ["2026-07-18"]) == (0, 24 * 60)
# férié légal (14 juillet) : journée entière même en milieu de semaine
assert fenetre_astreinte_jour("2026-07-14", ["2026-07-13"]) == (0, 24 * 60)
# férié marqué à la main : idem
assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14"], {"2026-07-15"}) == (0, 24 * 60)
def test_decompte_hors_fenetre_18h_18h_ignore():
@ -454,21 +480,58 @@ def test_decompte_hors_fenetre_18h_18h_ignore():
def test_compute_week_intervention_avant_18h_hors_astreinte(freeze_today):
"""Mardi AS : l'event 16:00→17:00 (avant 18:00) ne compte pas ; le même
créneau à 19:00→20:00 (dans la fenêtre) compte 1h (Journée ×1)."""
"""Mercredi ouvré AS : l'event 16:00→17:00 (avant 18:00) ne compte pas ; le
même créneau à 19:00→20:00 (dans la fenêtre) compte 1h (Journée ×1)."""
pointages = [
_pointage("2026-07-13"), _pointage("2026-07-16"), _pointage("2026-07-17"),
_pointage("2026-07-14", astreinte_events=[{"debut": "16:00", "fin": "17:00"}]),
_pointage("2026-07-15"),
_pointage("2026-07-13"), _pointage("2026-07-14"), _pointage("2026-07-16"),
_pointage("2026-07-17"),
_pointage("2026-07-15", astreinte_events=[{"debut": "16:00", "fin": "17:00"}]),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-15"])
assert result["astreinte"]["total_min"] == 0
assert result["jours"][1]["astreinte_min"] == 0
pointages[3] = _pointage("2026-07-14", astreinte_events=[{"debut": "19:00", "fin": "20:00"}])
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
assert result["jours"][2]["astreinte_min"] == 0
# le même créneau le soir, dans la fenêtre 18:00 → 18:00, compte (Journée ×1)
pointages[4] = _pointage("2026-07-15", astreinte_events=[{"debut": "19:00", "fin": "20:00"}])
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-15"])
assert result["astreinte"]["total_min"] == 60
assert result["astreinte"]["total_pondere_min"] == 120 # 14 juillet : férié ×2
assert result["astreinte"]["total_pondere_min"] == 60
def test_compute_week_astreinte_weekend_journee_entiere(freeze_today):
"""Vendredi AS : le samedi compte de 0h00 à 0h00 — l'intervention 20:00→21:00
du samedi (après la borne 18:00 des jours ouvrés) est de l'astreinte (Nuit ×1,5)."""
pointages = [
_pointage("2026-07-13"), _pointage("2026-07-14"), _pointage("2026-07-15"),
_pointage("2026-07-16"),
_pointage("2026-07-17", astreinte_events=[{"debut": "20:00", "fin": "21:00"}]),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-17"])
# l'événement saisi sur le vendredi est dans sa fenêtre 18:00 → 18:00
vendredi = result["jours"][4]
assert vendredi["astreinte_min"] == 60
def test_decompte_fenetre_samedi_soir_apres_18h():
"""Samedi lendemain d'astreinte : 20:00→21:00 compte (journée entière),
4 tranches en Nuit ×1,5 = 1h30 pondérées."""
fen = fenetre_astreinte_jour("2026-07-18", ["2026-07-17"])
d = decompte_astreinte("2026-07-18", [{"debut": "20:00", "fin": "21:00"}], None, fen)
assert d["total_min"] == 60
assert d["categories"][0]["nom"] == "Nuit"
assert d["total_pondere_min"] == 90
def test_compute_week_ferie_journee_entiere_ratio_x2(freeze_today):
"""Lundi AS, intervention le mardi 14 juillet (férié légal) : journée entière
0h00 → 0h00, ratio Dimanche / férié ×2 (10:00→12:00 = 2h → 4h pondérées)."""
pointages = [
_pointage("2026-07-13"),
_pointage("2026-07-14", astreinte_events=[{"debut": "10:00", "fin": "12:00"}]),
_pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-13"])
assert result["astreinte"]["total_min"] == 120
assert result["astreinte"]["total_pondere_min"] == 240 # férié ×2
def test_compute_week_astreinte_samedi_dimanche(freeze_today):

145
app/tests/test_ferie.py Normal file
View File

@ -0,0 +1,145 @@
"""Jours fériés marqués à la main : stockage, décompte (dû 0, fenêtre d'astreinte
0h00 → 0h00) et ratios d'astreinte éditables depuis la page Réglages."""
import sys
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
import models # noqa: F401
sys.path.insert(0, ".")
import main
from fastapi.testclient import TestClient
from calcul import compute_week, heures_dues
client = TestClient(main.app)
HEURES_JOUR_MIN = 468 # 39h / 5j
def _login(uid: str) -> dict:
import bcrypt
models.save_auth(uid, email=f"{uid}@x.fr",
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
r = client.post("/login/password",
data={"email": f"{uid}@x.fr", "password": "x" * 12},
follow_redirects=False)
assert r.status_code == 302, r.text
return {"user_id": r.cookies.get("user_id")}
def _pointage(date, astreinte_events=None):
return {"date": date, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None,
"astreinte_events": astreinte_events or []}
# ── Stockage ──────────────────────────────────────────────────────────────────
def test_toggle_ferie_persiste():
models.toggle_ferie("2026-07-15", "u-ferie")
assert models.load_feries("u-ferie") == ["2026-07-15"]
models.toggle_ferie("2026-07-15", "u-ferie")
assert models.load_feries("u-ferie") == []
# ── Décompte ──────────────────────────────────────────────────────────────────
def test_heures_dues_ferie_zero():
"""Un mercredi marqué férié ne doit aucune heure, congés ou pas."""
assert heures_dues("2026-07-15", [], HEURES_JOUR_MIN, ferie=True) == 0
assert heures_dues("2026-07-15", [], HEURES_JOUR_MIN, ferie=True,
retour_min=10 * 60) == 0
def test_compute_week_ferie_du_zero_jour_complet(freeze_today):
"""Mercredi marqué F : dû 0, jour complet d'office, badge férié, heures
pointées toujours comptées (delta positif)."""
pointages = [
_pointage("2026-07-13", None), _pointage("2026-07-14", None),
_pointage("2026-07-15", None), _pointage("2026-07-16", None),
_pointage("2026-07-17", None),
]
pointages[2].update(matin_entree="09:00", matin_sortie="12:24",
aprem_entree="13:30", aprem_sortie="17:54")
result = compute_week(pointages, [], HEURES_JOUR_MIN, {}, [], None, {"2026-07-15"})
mercredi = result["jours"][2]
assert mercredi["ferie"] is True
assert mercredi["du_min"] == 0
assert mercredi["is_complete"] is True
assert mercredi["travaille_min"] == HEURES_JOUR_MIN # pointé : compte en delta
assert result["jours"][1]["ferie"] is False
assert result["jours"][3]["ferie"] is False
def test_compute_week_ferie_etend_fenetre_astreinte(freeze_today):
"""AS mardi + F mercredi : l'intervention de mercredi 20:00→21:00 (après la
borne 18:00 des jours ouvrés) compte — Nuit ×1,5 = 1h30 pondérées."""
pointages = [
_pointage("2026-07-13", None),
_pointage("2026-07-14", None),
_pointage("2026-07-15", [{"debut": "20:00", "fin": "21:00"}]),
_pointage("2026-07-16", None), _pointage("2026-07-17", None),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, {}, ["2026-07-14"], None,
{"2026-07-15"})
assert result["jours"][2]["astreinte_min"] == 60
assert result["astreinte"]["total_pondere_min"] == 90
# ── Routes ────────────────────────────────────────────────────────────────────
def test_route_toggle_ferie():
cookies = _login("u-ferie-route")
r = client.post("/ferie/2026-07-15/toggle", cookies=cookies, follow_redirects=False)
assert r.status_code == 303
assert models.load_feries("u-ferie-route") == ["2026-07-15"]
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
assert r.status_code == 200
assert "ferie-on" in r.text # bouton F actif
assert "ferie-badge" in r.text # badge « férié » sur la ligne du jour
client.post("/ferie/2026-07-15/toggle", cookies=cookies, follow_redirects=False)
assert models.load_feries("u-ferie-route") == []
def test_settings_astreinte_facteurs_editables():
"""POST de règles valides : enregistrées et rechargées ; règle au nom vide
supprimée ; ratio invalide rejette tout sans rien changer."""
cookies = _login("u-facteurs")
avant = models.load_facteurs_astreinte()
data = {
"f_nom_0": "Nuit", "f_debut_0": "20:00", "f_fin_0": "08:00", "f_facteur_0": "1.5",
"f_jours_0": ["semaine", "dim"],
"f_nom_1": "Garde", "f_debut_1": "18:00", "f_fin_1": "09:00", "f_facteur_1": "2,5",
"f_jours_1": ["sam"],
}
r = client.post("/settings/astreinte-facteurs", cookies=cookies, data=data,
follow_redirects=False)
assert r.status_code == 303
assert "facteurs_saved=1" in r.headers["location"]
facteurs = models.load_facteurs_astreinte()
assert [f["nom"] for f in facteurs] == ["Nuit", "Garde"]
assert facteurs[1]["facteur"] == 2.5 # virgule acceptée
assert facteurs[1]["jours"] == ["sam"]
# suppression par nom vidé : la règle disparaît
r = client.post("/settings/astreinte-facteurs", cookies=cookies, data={
"f_nom_0": "Nuit", "f_debut_0": "20:00", "f_fin_0": "08:00", "f_facteur_0": "1.5",
"f_jours_0": ["semaine"],
"f_nom_1": "", "f_debut_1": "18:00", "f_fin_1": "09:00", "f_facteur_1": "2",
}, follow_redirects=False)
assert r.status_code == 303
assert [f["nom"] for f in models.load_facteurs_astreinte()] == ["Nuit"]
# ratio invalide : rejet, rien n'est modifié
r = client.post("/settings/astreinte-facteurs", cookies=cookies, data={
"f_nom_0": "Bug", "f_debut_0": "20:00", "f_fin_0": "08:00",
"f_facteur_0": "0", "f_jours_0": ["semaine"],
}, follow_redirects=False)
assert r.status_code == 303
assert "facteurs_error=1" in r.headers["location"]
assert [f["nom"] for f in models.load_facteurs_astreinte()] == ["Nuit"]
# remise aux règles par défaut pour les autres tests
models.save_facteurs_astreinte(avant)