From 5088182f20e2bba449c0fed18e04b3c0dde32b6c Mon Sep 17 00:00:00 2001
From: maxsoch
Date: Tue, 14 Jul 2026 19:17:27 +0200
Subject: [PATCH] =?UTF-8?q?Cr=C3=A9ation=20rapide=20depuis=20le=20scan,=20?=
=?UTF-8?q?ajustement=20de=20stock=20flexible,=20filtres=20mat=C3=A9riels?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Un code-barre inconnu scanné propose désormais le même formulaire de
création que /materiels au lieu d'un simple message d'erreur. La
quantité peut être ajustée d'un delta choisi librement (pas figé à ±1)
ou définie directement à une valeur absolue après un inventaire. La
racine "/" sert directement la liste des matériels sans redirection.
La page Matériels gagne un filtre par catégorie, un filtre stock bas,
et un tri incluant un regroupement par catégorie.
---
CLAUDE.md | 18 ++++
app/categories_arbre.py | 11 +++
app/main.py | 10 +--
app/routers/materiels.py | 77 ++++++++++++++---
app/routers/scan.py | 101 ++++++++++++++++++----
app/static/style.css | 105 ++++++++++++++++++++---
app/templates/materiels.html | 145 +++++++++++++++++++++-----------
app/templates/scan.html | 65 ++++++++++++--
tests/test_listing_materiels.py | 113 +++++++++++++++++++++++++
tests/test_materiels.py | 75 +++++++++++++++--
10 files changed, 609 insertions(+), 111 deletions(-)
create mode 100644 tests/test_listing_materiels.py
diff --git a/CLAUDE.md b/CLAUDE.md
index 25877b8..9d49dc1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -91,6 +91,24 @@ complet d'un matériel ne s'affiche que dans la ligne dépliée (voir
`.formulaire-edition` dans `style.css`), pas sur toutes les lignes en
permanence.
+`/materiels` (aussi servie sur `/`, voir `main.py`) combine recherche
+(`?q=`), filtre par catégorie — elle et toutes ses sous-catégories, via
+`ids_sous_arbre()` — filtre stock bas (`?stock_bas=1`) et tri (`?tri=`),
+tous combinables. `tri=categorie` trie par chemin de catégorie puis nom,
+ce qui rend les matériels d'une même catégorie consécutifs : le
+regroupement visuel (en-têtes `.groupe-categorie-entete`) vient de ce tri,
+pas d'une logique de regroupement séparée à maintenir.
+
+L'écran de scan (`/scan`) gère 3 cas après un scan : matériel trouvé
+(étiquette + contrôles de stock), code inconnu (formulaire de création
+identique à celui de `/materiels`, avec le code-barre pré-rempli, pour
+créer sans changer de page), ou matériel venant d'être créé (même rendu
+que "trouvé"). Deux façons distinctes d'agir sur le stock, volontairement
+séparées pour ne jamais laisser d'ambiguïté sur ce qu'un nombre représente :
+`/scan/{id}/ajuster` applique un delta choisi (pas fixé à ±1) via un champ
+« sens » (ajouter/retirer), `/scan/{id}/definir` fixe la quantité absolue
+(utile après un inventaire physique).
+
## Emails d'alerte
Seuil configurable par matériel (`Materiel.seuil_alerte`), liste globale de
diff --git a/app/categories_arbre.py b/app/categories_arbre.py
index bc68b95..0c747b6 100644
--- a/app/categories_arbre.py
+++ b/app/categories_arbre.py
@@ -23,3 +23,14 @@ def aplatir(categories: list[Categorie], profondeur: int = 0) -> list[tuple[int,
resultat.append((profondeur, categorie))
resultat.extend(aplatir(categorie.enfants, profondeur + 1))
return resultat
+
+
+def ids_sous_arbre(categorie: Categorie) -> set[int]:
+ """L'id de cette catégorie et de tous ses descendants, à n'importe
+ quelle profondeur. Sert au filtre "cette catégorie" sur /materiels :
+ on veut aussi les matériels rattachés à ses sous-catégories, pas
+ seulement à elle-même."""
+ ids = {categorie.id}
+ for enfant in categorie.enfants:
+ ids |= ids_sous_arbre(enfant)
+ return ids
diff --git a/app/main.py b/app/main.py
index 10e0ede..50be78c 100644
--- a/app/main.py
+++ b/app/main.py
@@ -7,7 +7,6 @@ Lancement en local : uvicorn app.main:app --reload
from contextlib import asynccontextmanager
from fastapi import FastAPI
-from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from app.database import init_db
@@ -26,12 +25,9 @@ app = FastAPI(title="Gestion de stock IT", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(categories.router)
+# "/" est enregistrée directement sur le routeur materiels (voir
+# lister_materiels) pour afficher la liste des matériels sans redirection
+# HTTP : l'URL reste "/" plutôt que de rebondir vers "/materiels".
app.include_router(materiels.router)
app.include_router(scan.router)
app.include_router(destinataires.router)
-
-
-@app.get("/")
-def racine() -> RedirectResponse:
- """La page d'accueil redirige directement vers la liste des matériels."""
- return RedirectResponse(url="/materiels")
diff --git a/app/routers/materiels.py b/app/routers/materiels.py
index ddbc9e6..bbc0456 100644
--- a/app/routers/materiels.py
+++ b/app/routers/materiels.py
@@ -5,42 +5,97 @@ Un "matériel" représente un type d'objet suivi en stock (nom + code-barre
optionnel + quantité), pas un exemplaire individuel — voir app/models.py.
"""
+from itertools import groupby
+
from fastapi import APIRouter, Depends, Form
from fastapi.requests import Request
from fastapi.responses import RedirectResponse
from sqlmodel import Session, select
-from app.categories_arbre import aplatir, categories_racines
+from app.categories_arbre import aplatir, categories_racines, ids_sous_arbre
from app.database import get_session
from app.email_alerts import verifier_et_alerter
-from app.models import Materiel
+from app.models import Categorie, Materiel
from app.templates_engine import templates
router = APIRouter()
+# Chaque clé de tri associe une fonction de clé de tri Python et un
+# indicateur d'ordre inverse. "categorie" trie par chemin complet puis nom
+# : les matériels d'une même catégorie se retrouvent ainsi consécutifs,
+# ce qui permet de les regrouper sous un même en-tête dans le template
+# sans logique de regroupement séparée à maintenir.
+CLES_TRI = {
+ "nom_asc": (lambda m: m.nom.lower(), False),
+ "nom_desc": (lambda m: m.nom.lower(), True),
+ "quantite_asc": (lambda m: m.quantite, False),
+ "quantite_desc": (lambda m: m.quantite, True),
+ "categorie": (lambda m: (m.categorie.chemin().lower(), m.nom.lower()), False),
+}
+
+@router.get("/")
@router.get("/materiels")
-def lister_materiels(request: Request, q: str = "", session: Session = Depends(get_session)):
- """Liste les matériels, filtrés par nom ou code-barre si ?q=... est
- fourni (recherche insensible à la casse). Indispensable dès que la
- liste devient longue : pas de pagination, juste un filtre texte."""
- tous = session.exec(select(Materiel)).all()
+def lister_materiels(
+ request: Request,
+ q: str = "",
+ categorie_id: str = "",
+ stock_bas: bool = False,
+ tri: str = "nom_asc",
+ session: Session = Depends(get_session),
+):
+ """Liste les matériels avec recherche, filtre par catégorie (elle et
+ ses sous-catégories), filtre "stock bas uniquement" et tri — tous
+ combinables, indispensable dès que la liste devient longue.
+
+ Tout est fait côté serveur en Python (pas de pagination, juste des
+ filtres) : à l'échelle d'un stock IT d'un département, ça reste très
+ largement suffisant et évite d'avoir à gérer un état côté client.
+ """
+ materiels = list(session.exec(select(Materiel)).all())
+
if q:
q_normalise = q.lower()
materiels = [
m
- for m in tous
+ for m in materiels
if q_normalise in m.nom.lower()
or (m.code_barre and q_normalise in m.code_barre.lower())
]
- else:
- materiels = tous
+
+ if categorie_id:
+ categorie_selectionnee = session.get(Categorie, int(categorie_id))
+ ids_valides = ids_sous_arbre(categorie_selectionnee) if categorie_selectionnee else set()
+ materiels = [m for m in materiels if m.categorie_id in ids_valides]
+
+ if stock_bas:
+ materiels = [
+ m for m in materiels if m.seuil_alerte is not None and m.quantite < m.seuil_alerte
+ ]
+
+ cle, inverse = CLES_TRI.get(tri, CLES_TRI["nom_asc"])
+ materiels.sort(key=cle, reverse=inverse)
+
+ groupes = None
+ if tri == "categorie":
+ groupes = [
+ (chemin, list(items))
+ for chemin, items in groupby(materiels, key=lambda m: m.categorie.chemin())
+ ]
categories_aplaties = aplatir(categories_racines(session))
return templates.TemplateResponse(
request,
"materiels.html",
- {"materiels": materiels, "categories_aplaties": categories_aplaties, "q": q},
+ {
+ "materiels": materiels,
+ "groupes": groupes,
+ "categories_aplaties": categories_aplaties,
+ "q": q,
+ "categorie_id": categorie_id,
+ "stock_bas": stock_bas,
+ "tri": tri,
+ },
)
diff --git a/app/routers/scan.py b/app/routers/scan.py
index 21285c1..5841fa0 100644
--- a/app/routers/scan.py
+++ b/app/routers/scan.py
@@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, Form
from fastapi.requests import Request
from sqlmodel import Session, select
+from app.categories_arbre import aplatir, categories_racines
from app.database import get_session
from app.email_alerts import verifier_et_alerter
from app.models import Materiel
@@ -20,44 +21,112 @@ from app.templates_engine import templates
router = APIRouter()
+def _contexte(session: Session, **kwargs) -> dict:
+ """Contexte commun à toutes les réponses de l'écran de scan.
+
+ categories_aplaties est toujours nécessaire : que le matériel scanné
+ existe ou non, on peut avoir besoin du