Création rapide depuis le scan, ajustement de stock flexible, filtres matériels
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.
This commit is contained in:
parent
7ce6540924
commit
5088182f20
18
CLAUDE.md
18
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
app/main.py
10
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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <select> de catégorie (menu de
|
||||
création rapide si le code est inconnu).
|
||||
"""
|
||||
contexte = {
|
||||
"materiel": None,
|
||||
"code_barre_introuvable": None,
|
||||
"categories_aplaties": aplatir(categories_racines(session)),
|
||||
}
|
||||
contexte.update(kwargs)
|
||||
return contexte
|
||||
|
||||
|
||||
@router.get("/scan")
|
||||
def page_scan(request: Request):
|
||||
def page_scan(request: Request, session: Session = Depends(get_session)):
|
||||
"""Affiche le champ de scan, vide."""
|
||||
return templates.TemplateResponse(
|
||||
request, "scan.html", {"materiel": None, "erreur": None}
|
||||
)
|
||||
return templates.TemplateResponse(request, "scan.html", _contexte(session))
|
||||
|
||||
|
||||
@router.post("/scan")
|
||||
def rechercher_par_code_barre(
|
||||
request: Request, code_barre: str = Form(...), session: Session = Depends(get_session)
|
||||
):
|
||||
"""Cherche le matériel correspondant au code-barre scanné."""
|
||||
materiel = session.exec(
|
||||
select(Materiel).where(Materiel.code_barre == code_barre)
|
||||
).first()
|
||||
erreur = None if materiel else f"Aucun matériel avec le code-barre « {code_barre} »."
|
||||
"""Cherche le matériel correspondant au code-barre scanné.
|
||||
|
||||
S'il n'existe pas encore, on ne se contente pas d'un message d'erreur :
|
||||
on propose directement le même formulaire de création que sur
|
||||
/materiels, avec le code-barre déjà rempli, pour ne pas avoir à quitter
|
||||
l'écran de scan (voir code_barre_introuvable dans scan.html).
|
||||
"""
|
||||
materiel = session.exec(select(Materiel).where(Materiel.code_barre == code_barre)).first()
|
||||
if materiel:
|
||||
return templates.TemplateResponse(
|
||||
request, "scan.html", _contexte(session, materiel=materiel)
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request, "scan.html", {"materiel": materiel, "erreur": erreur}
|
||||
request, "scan.html", _contexte(session, code_barre_introuvable=code_barre)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/scan/creer")
|
||||
def creer_depuis_scan(
|
||||
request: Request,
|
||||
nom: str = Form(...),
|
||||
code_barre: str = Form(""),
|
||||
categorie_id: int = Form(...),
|
||||
quantite: int = Form(0),
|
||||
seuil_alerte: str = Form(""),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Crée le matériel correspondant à un code-barre scanné qui n'existait
|
||||
pas encore, puis affiche directement son étiquette (comme s'il venait
|
||||
d'être scanné avec succès) — sans repasser par /materiels."""
|
||||
materiel = Materiel(
|
||||
nom=nom,
|
||||
code_barre=code_barre or None,
|
||||
categorie_id=categorie_id,
|
||||
quantite=quantite,
|
||||
seuil_alerte=int(seuil_alerte) if seuil_alerte else None,
|
||||
)
|
||||
session.add(materiel)
|
||||
session.commit()
|
||||
session.refresh(materiel)
|
||||
verifier_et_alerter(materiel, session)
|
||||
return templates.TemplateResponse(request, "scan.html", _contexte(session, materiel=materiel))
|
||||
|
||||
|
||||
@router.post("/scan/{materiel_id}/ajuster")
|
||||
def ajuster_quantite(
|
||||
request: Request,
|
||||
materiel_id: int,
|
||||
delta: int = Form(...),
|
||||
quantite: int = Form(...),
|
||||
sens: str = Form(...),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Ajuste la quantité en stock d'un matériel (+1 en réception, -1 en
|
||||
sortie) puis vérifie si une alerte de stock bas doit être envoyée."""
|
||||
"""Ajuste la quantité en stock d'un nombre choisi librement par
|
||||
l'utilisateur (pas un pas fixe de ±1) : "sens" indique si on ajoute
|
||||
(réception) ou retire (sortie) cette quantité."""
|
||||
materiel = session.get(Materiel, materiel_id)
|
||||
if materiel:
|
||||
delta = quantite if sens == "ajouter" else -quantite
|
||||
materiel.quantite = max(0, materiel.quantite + delta)
|
||||
session.add(materiel)
|
||||
session.commit()
|
||||
session.refresh(materiel)
|
||||
verifier_et_alerter(materiel, session)
|
||||
return templates.TemplateResponse(
|
||||
request, "scan.html", {"materiel": materiel, "erreur": None}
|
||||
)
|
||||
return templates.TemplateResponse(request, "scan.html", _contexte(session, materiel=materiel))
|
||||
|
||||
|
||||
@router.post("/scan/{materiel_id}/definir")
|
||||
def definir_quantite(
|
||||
request: Request,
|
||||
materiel_id: int,
|
||||
quantite: int = Form(...),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Fixe directement la quantité en stock à une valeur donnée, pour
|
||||
corriger un écart d'inventaire sans avoir à calculer un delta."""
|
||||
materiel = session.get(Materiel, materiel_id)
|
||||
if materiel:
|
||||
materiel.quantite = max(0, quantite)
|
||||
session.add(materiel)
|
||||
session.commit()
|
||||
session.refresh(materiel)
|
||||
verifier_et_alerter(materiel, session)
|
||||
return templates.TemplateResponse(request, "scan.html", _contexte(session, materiel=materiel))
|
||||
|
|
|
|||
|
|
@ -121,20 +121,55 @@ details[open] > summary .noeud-chevron {
|
|||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.barre-recherche {
|
||||
.barre-recherche,
|
||||
.barre-filtres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.barre-recherche input[type="search"] {
|
||||
.barre-recherche input[type="search"],
|
||||
.barre-filtres input[type="search"] {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
background: var(--papier-creux);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.barre-filtres select {
|
||||
background: var(--papier-creux);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.case-stock-bas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--acier);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.case-stock-bas input {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* En-tête de groupe quand la liste des matériels est regroupée par
|
||||
catégorie (tri=categorie) : un simple séparateur textuel, pas une carte
|
||||
à part entière, pour rester léger sur une longue liste. */
|
||||
.groupe-categorie-entete {
|
||||
font-family: var(--police-donnee);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--acier);
|
||||
padding: 0.9rem 0.5rem 0.3rem;
|
||||
}
|
||||
|
||||
.lien-effacer {
|
||||
font-size: 0.85rem;
|
||||
color: var(--acier);
|
||||
|
|
@ -436,11 +471,21 @@ button:hover {
|
|||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.etiquette-erreur {
|
||||
/* Code scanné mais inconnu : même carte "étiquette" que le résultat d'un
|
||||
scan trouvé, mais avec un formulaire de création à la place du détail
|
||||
du stock — pour créer le matériel sans quitter l'écran de scan. */
|
||||
.etiquette-creation .etiquette-nom {
|
||||
color: var(--brique);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.etiquette-sous-titre {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
color: var(--acier);
|
||||
}
|
||||
|
||||
.etiquette-formulaire {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -528,16 +573,54 @@ button:hover {
|
|||
background: var(--brique);
|
||||
}
|
||||
|
||||
/*
|
||||
* Deux gestes distincts sur le stock : "ajuster" (formulaire-ajustement,
|
||||
* un delta relatif — pas forcément ±1) et "définir" (formulaire-definir,
|
||||
* une valeur absolue après un inventaire physique). Volontairement deux
|
||||
* formulaires séparés pour ne jamais laisser un doute sur ce qu'un chiffre
|
||||
* saisi représente.
|
||||
*/
|
||||
.etiquette-controles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
background: var(--papier-creux);
|
||||
}
|
||||
|
||||
.etiquette-champ-quantite {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--acier);
|
||||
}
|
||||
|
||||
.etiquette-champ-quantite input {
|
||||
font-family: var(--police-donnee);
|
||||
font-size: 1.1rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.formulaire-definir {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.formulaire-definir .etiquette-champ-quantite {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.etiquette-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: var(--acier-clair);
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.etiquette-actions form {
|
||||
display: contents;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.bouton-scan {
|
||||
|
|
|
|||
|
|
@ -1,67 +1,112 @@
|
|||
{% extends "base.html" %}
|
||||
{% block titre %}Matériels - Gestion de stock IT{% endblock %}
|
||||
{% block contenu %}
|
||||
<h1>Matériels</h1>
|
||||
|
||||
<form method="get" action="/materiels" class="barre-recherche">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Rechercher par nom ou code-barre…">
|
||||
{% if q %}<a href="/materiels" class="lien-effacer">Effacer</a>{% endif %}
|
||||
</form>
|
||||
|
||||
{#
|
||||
Macro pour une ligne de matériel : utilisée à la fois en affichage plat
|
||||
et en affichage groupé par catégorie (tri=categorie), pour ne pas
|
||||
dupliquer le HTML entre les deux.
|
||||
|
||||
Chaque ligne est repliée par défaut (juste le résumé, pas les champs
|
||||
d'édition) : avec beaucoup de matériels, afficher 5 <input> par ligne en
|
||||
permanence rend la liste illisible et pousse le contenu hors de l'écran.
|
||||
On ne dépense la largeur d'un vrai formulaire que sur la ligne qu'on
|
||||
déplie. Voir aussi CLAUDE.md pour le contexte de ce choix.
|
||||
#}
|
||||
<ul class="liste-materiels">
|
||||
{% for materiel in materiels %}
|
||||
{% set sous_seuil = materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte %}
|
||||
<li>
|
||||
<details class="ligne-materiel">
|
||||
<summary>
|
||||
<span class="noeud-chevron" aria-hidden="true"></span>
|
||||
<span class="materiel-nom">{{ materiel.nom }}</span>
|
||||
<span class="materiel-categorie">{{ materiel.categorie.chemin() }}</span>
|
||||
<span class="materiel-code">{{ materiel.code_barre or "—" }}</span>
|
||||
<span class="materiel-quantite {{ 'quantite-basse' if sous_seuil else '' }}">{{ materiel.quantite }}</span>
|
||||
</summary>
|
||||
{% macro ligne_materiel(materiel, categories_aplaties) %}
|
||||
{% set sous_seuil = materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte %}
|
||||
<li>
|
||||
<details class="ligne-materiel">
|
||||
<summary>
|
||||
<span class="noeud-chevron" aria-hidden="true"></span>
|
||||
<span class="materiel-nom">{{ materiel.nom }}</span>
|
||||
<span class="materiel-categorie">{{ materiel.categorie.chemin() }}</span>
|
||||
<span class="materiel-code">{{ materiel.code_barre or "—" }}</span>
|
||||
<span class="materiel-quantite {{ 'quantite-basse' if sous_seuil else '' }}">{{ materiel.quantite }}</span>
|
||||
</summary>
|
||||
|
||||
<form method="post" action="/materiels/{{ materiel.id }}/modifier" class="formulaire-edition">
|
||||
<label>Nom
|
||||
<input type="text" name="nom" value="{{ materiel.nom }}" required>
|
||||
</label>
|
||||
<label>Catégorie
|
||||
<select name="categorie_id" required>
|
||||
{% for profondeur, categorie in categories_aplaties %}
|
||||
<option value="{{ categorie.id }}" {{ "selected" if categorie.id == materiel.categorie_id else "" }}>
|
||||
{{ "— " * profondeur }}{{ categorie.nom }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Code-barre
|
||||
<input type="text" name="code_barre" value="{{ materiel.code_barre or '' }}">
|
||||
</label>
|
||||
<label>Quantité
|
||||
<input type="number" name="quantite" value="{{ materiel.quantite }}" min="0" required>
|
||||
</label>
|
||||
<label>Seuil d'alerte
|
||||
<input type="number" name="seuil_alerte" value="{{ materiel.seuil_alerte if materiel.seuil_alerte is not none else '' }}" min="0">
|
||||
</label>
|
||||
<div class="formulaire-actions">
|
||||
<button type="submit">Enregistrer</button>
|
||||
<button type="submit" formaction="/materiels/{{ materiel.id }}/supprimer" class="bouton-danger">Supprimer</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</li>
|
||||
<form method="post" action="/materiels/{{ materiel.id }}/modifier" class="formulaire-edition">
|
||||
<label>Nom
|
||||
<input type="text" name="nom" value="{{ materiel.nom }}" required>
|
||||
</label>
|
||||
<label>Catégorie
|
||||
<select name="categorie_id" required>
|
||||
{% for profondeur, categorie in categories_aplaties %}
|
||||
<option value="{{ categorie.id }}" {{ "selected" if categorie.id == materiel.categorie_id else "" }}>
|
||||
{{ "— " * profondeur }}{{ categorie.nom }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Code-barre
|
||||
<input type="text" name="code_barre" value="{{ materiel.code_barre or '' }}">
|
||||
</label>
|
||||
<label>Quantité
|
||||
<input type="number" name="quantite" value="{{ materiel.quantite }}" min="0" required>
|
||||
</label>
|
||||
<label>Seuil d'alerte
|
||||
<input type="number" name="seuil_alerte" value="{{ materiel.seuil_alerte if materiel.seuil_alerte is not none else '' }}" min="0">
|
||||
</label>
|
||||
<div class="formulaire-actions">
|
||||
<button type="submit">Enregistrer</button>
|
||||
<button type="submit" formaction="/materiels/{{ materiel.id }}/supprimer" class="bouton-danger">Supprimer</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block contenu %}
|
||||
<h1>Matériels</h1>
|
||||
|
||||
<form method="get" action="/materiels" class="barre-filtres">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Rechercher par nom ou code-barre…">
|
||||
|
||||
<select name="categorie_id">
|
||||
<option value="">Toutes les catégories</option>
|
||||
{% for profondeur, categorie in categories_aplaties %}
|
||||
<option value="{{ categorie.id }}" {{ "selected" if categorie_id == categorie.id|string else "" }}>
|
||||
{{ "— " * profondeur }}{{ categorie.nom }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<select name="tri">
|
||||
<option value="nom_asc" {{ "selected" if tri == "nom_asc" else "" }}>Nom (A → Z)</option>
|
||||
<option value="nom_desc" {{ "selected" if tri == "nom_desc" else "" }}>Nom (Z → A)</option>
|
||||
<option value="quantite_asc" {{ "selected" if tri == "quantite_asc" else "" }}>Quantité (croissant)</option>
|
||||
<option value="quantite_desc" {{ "selected" if tri == "quantite_desc" else "" }}>Quantité (décroissant)</option>
|
||||
<option value="categorie" {{ "selected" if tri == "categorie" else "" }}>Regrouper par catégorie</option>
|
||||
</select>
|
||||
|
||||
<label class="case-stock-bas">
|
||||
<input type="checkbox" name="stock_bas" value="1" {{ "checked" if stock_bas else "" }}>
|
||||
Stock bas uniquement
|
||||
</label>
|
||||
|
||||
<button type="submit">Filtrer</button>
|
||||
{% if q or categorie_id or stock_bas or tri != "nom_asc" %}
|
||||
<a href="/materiels" class="lien-effacer">Réinitialiser</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
<ul class="liste-materiels">
|
||||
{% if groupes %}
|
||||
{% for chemin, groupe in groupes %}
|
||||
<li class="groupe-categorie-entete">{{ chemin }}</li>
|
||||
{% for materiel in groupe %}
|
||||
{{ ligne_materiel(materiel, categories_aplaties) }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for materiel in materiels %}
|
||||
{{ ligne_materiel(materiel, categories_aplaties) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if not materiels %}
|
||||
<li class="liste-vide">
|
||||
{% if q %}Aucun matériel ne correspond à « {{ q }} ».{% else %}Aucun matériel pour l'instant.{% endif %}
|
||||
{% if q or categorie_id or stock_bas %}Aucun matériel ne correspond à ces filtres.{% else %}Aucun matériel pour l'instant.{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<h2>Ajouter un matériel</h2>
|
||||
|
|
|
|||
|
|
@ -29,8 +29,39 @@
|
|||
<div class="laser-ligne" aria-hidden="true"></div>
|
||||
</form>
|
||||
|
||||
{% if erreur %}
|
||||
<p class="etiquette-erreur">{{ erreur }}</p>
|
||||
{% if code_barre_introuvable %}
|
||||
{#
|
||||
Code scanné mais aucun matériel ne le porte : au lieu d'un simple
|
||||
message d'erreur, on propose le même formulaire de création rapide que
|
||||
sur /materiels, avec le code-barre déjà rempli, pour ne pas avoir à
|
||||
quitter l'écran de scan.
|
||||
#}
|
||||
<article class="etiquette-resultat etiquette-creation">
|
||||
<div class="rayures-code-barre etiquette-rayures" aria-hidden="true"></div>
|
||||
<p class="etiquette-fil">Code inconnu</p>
|
||||
<h2 class="etiquette-nom">« {{ code_barre_introuvable }} »</h2>
|
||||
|
||||
{% if categories_aplaties %}
|
||||
<form method="post" action="/scan/creer" class="formulaire-grille etiquette-formulaire">
|
||||
<input type="hidden" name="code_barre" value="{{ code_barre_introuvable }}">
|
||||
<label>Nom <input type="text" name="nom" required autofocus></label>
|
||||
<label>Catégorie
|
||||
<select name="categorie_id" required>
|
||||
{% for profondeur, categorie in categories_aplaties %}
|
||||
<option value="{{ categorie.id }}">{{ "— " * profondeur }}{{ categorie.nom }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Quantité initiale <input type="number" name="quantite" value="0" min="0"></label>
|
||||
<label>Seuil d'alerte <input type="number" name="seuil_alerte" min="0"></label>
|
||||
<button type="submit">Créer et ajouter au stock</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="etiquette-sous-titre">
|
||||
Créez d'abord une <a href="/categories">catégorie</a> avant de pouvoir créer ce matériel.
|
||||
</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
{% if materiel %}
|
||||
|
|
@ -57,14 +88,30 @@
|
|||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="etiquette-actions">
|
||||
<form method="post" action="/scan/{{ materiel.id }}/ajuster">
|
||||
<input type="hidden" name="delta" value="-1">
|
||||
<button type="submit" class="bouton-scan bouton-sortie">− Sortie</button>
|
||||
{#
|
||||
Deux façons distinctes d'agir sur le stock, pour ne pas mélanger les
|
||||
deux gestes : "ajuster" applique un delta (une quantité reçue ou
|
||||
sortie, pas forcément 1) ; "définir" corrige directement le total
|
||||
(utile après un inventaire physique, sans calculer d'écart).
|
||||
#}
|
||||
<div class="etiquette-controles">
|
||||
<form method="post" action="/scan/{{ materiel.id }}/ajuster" class="formulaire-ajustement">
|
||||
<label class="etiquette-champ-quantite">
|
||||
Quantité à ajuster
|
||||
<input type="number" name="quantite" value="1" min="1" required>
|
||||
</label>
|
||||
<div class="etiquette-actions">
|
||||
<button type="submit" name="sens" value="retirer" class="bouton-scan bouton-sortie">− Retirer</button>
|
||||
<button type="submit" name="sens" value="ajouter" class="bouton-scan bouton-reception">+ Ajouter</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/scan/{{ materiel.id }}/ajuster">
|
||||
<input type="hidden" name="delta" value="1">
|
||||
<button type="submit" class="bouton-scan bouton-reception">+ Réception</button>
|
||||
|
||||
<form method="post" action="/scan/{{ materiel.id }}/definir" class="formulaire-definir">
|
||||
<label class="etiquette-champ-quantite">
|
||||
Définir le stock à
|
||||
<input type="number" name="quantite" value="{{ materiel.quantite }}" min="0" required>
|
||||
</label>
|
||||
<button type="submit">Enregistrer</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
113
tests/test_listing_materiels.py
Normal file
113
tests/test_listing_materiels.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""
|
||||
Tests du filtre/tri de /materiels : recherche, filtre par catégorie (avec
|
||||
ses sous-catégories), filtre stock bas, et tri (dont le regroupement par
|
||||
catégorie), combinables entre eux.
|
||||
"""
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models import Categorie, Materiel
|
||||
|
||||
|
||||
def _arborescence(session: Session) -> dict[str, Categorie]:
|
||||
"""Ordinateurs > Portables, Réseau > Câbles — deux branches pour
|
||||
tester le filtre par catégorie et le regroupement."""
|
||||
ordinateurs = Categorie(nom="Ordinateurs")
|
||||
session.add(ordinateurs)
|
||||
session.commit()
|
||||
session.refresh(ordinateurs)
|
||||
portables = Categorie(nom="Portables", parent_id=ordinateurs.id)
|
||||
session.add(portables)
|
||||
session.commit()
|
||||
session.refresh(portables)
|
||||
|
||||
reseau = Categorie(nom="Réseau")
|
||||
session.add(reseau)
|
||||
session.commit()
|
||||
session.refresh(reseau)
|
||||
cables = Categorie(nom="Câbles", parent_id=reseau.id)
|
||||
session.add(cables)
|
||||
session.commit()
|
||||
session.refresh(cables)
|
||||
|
||||
return {"ordinateurs": ordinateurs, "portables": portables, "reseau": reseau, "cables": cables}
|
||||
|
||||
|
||||
def _materiels_demo(session: Session, categories: dict[str, Categorie]) -> None:
|
||||
session.add_all(
|
||||
[
|
||||
Materiel(
|
||||
nom="PC Dell XPS 13",
|
||||
categorie_id=categories["portables"].id,
|
||||
quantite=2,
|
||||
seuil_alerte=5,
|
||||
),
|
||||
Materiel(
|
||||
nom="PC Lenovo T14",
|
||||
categorie_id=categories["portables"].id,
|
||||
quantite=8,
|
||||
seuil_alerte=3,
|
||||
),
|
||||
Materiel(
|
||||
nom="Câble RJ45 2m",
|
||||
categorie_id=categories["cables"].id,
|
||||
quantite=40,
|
||||
seuil_alerte=10,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_filtre_par_categorie_inclut_les_sous_categories(client, session):
|
||||
categories = _arborescence(session)
|
||||
_materiels_demo(session, categories)
|
||||
|
||||
reponse = client.get("/materiels", params={"categorie_id": categories["ordinateurs"].id})
|
||||
|
||||
assert "PC Dell XPS 13" in reponse.text
|
||||
assert "PC Lenovo T14" in reponse.text
|
||||
assert "Câble RJ45 2m" not in reponse.text
|
||||
|
||||
|
||||
def test_filtre_stock_bas(client, session):
|
||||
categories = _arborescence(session)
|
||||
_materiels_demo(session, categories)
|
||||
|
||||
reponse = client.get("/materiels", params={"stock_bas": "1"})
|
||||
|
||||
assert "PC Dell XPS 13" in reponse.text # 2 < seuil 5
|
||||
assert "PC Lenovo T14" not in reponse.text # 8 >= seuil 3
|
||||
assert "Câble RJ45 2m" not in reponse.text # 40 >= seuil 10
|
||||
|
||||
|
||||
def test_tri_par_quantite_decroissant(client, session):
|
||||
categories = _arborescence(session)
|
||||
_materiels_demo(session, categories)
|
||||
|
||||
reponse = client.get("/materiels", params={"tri": "quantite_desc"})
|
||||
|
||||
position_cable = reponse.text.index("Câble RJ45 2m")
|
||||
position_lenovo = reponse.text.index("PC Lenovo T14")
|
||||
position_dell = reponse.text.index("PC Dell XPS 13")
|
||||
assert position_cable < position_lenovo < position_dell
|
||||
|
||||
|
||||
def test_tri_categorie_regroupe_les_materiels(client, session):
|
||||
categories = _arborescence(session)
|
||||
_materiels_demo(session, categories)
|
||||
|
||||
reponse = client.get("/materiels", params={"tri": "categorie"})
|
||||
|
||||
assert "groupe-categorie-entete" in reponse.text
|
||||
assert "Ordinateurs / Portables" in reponse.text
|
||||
assert "Réseau / Câbles" in reponse.text
|
||||
|
||||
|
||||
def test_recherche_et_filtre_categorie_combines(client, session):
|
||||
categories = _arborescence(session)
|
||||
_materiels_demo(session, categories)
|
||||
|
||||
reponse = client.get("/materiels", params={"q": "PC", "categorie_id": categories["reseau"].id})
|
||||
|
||||
assert "Aucun matériel ne correspond" in reponse.text
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
"""
|
||||
Tests du flux principal : créer une arborescence de catégories, créer un
|
||||
matériel, le retrouver par scan, ajuster son stock, et vérifier que
|
||||
l'alerte de stock bas se déclenche (elle est simplement affichée dans la
|
||||
console tant qu'aucun SMTP n'est configuré, voir app/email_alerts.py).
|
||||
matériel, le retrouver (ou le créer) par scan, ajuster son stock de deux
|
||||
façons différentes, et vérifier que l'alerte de stock bas se déclenche
|
||||
(elle est simplement affichée dans la console tant qu'aucun SMTP n'est
|
||||
configuré, voir app/email_alerts.py).
|
||||
"""
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
|
@ -47,6 +48,14 @@ def test_creation_materiel(client, session):
|
|||
assert materiel.quantite == 5
|
||||
|
||||
|
||||
def test_racine_affiche_la_liste_des_materiels(client, session):
|
||||
""" "/" doit afficher directement les matériels, pas rediriger."""
|
||||
reponse = client.get("/", follow_redirects=False)
|
||||
|
||||
assert reponse.status_code == 200
|
||||
assert "Matériels" in reponse.text
|
||||
|
||||
|
||||
def test_scan_trouve_le_materiel_par_code_barre(client, session):
|
||||
sous_categorie = _creer_sous_categorie(session)
|
||||
session.add(
|
||||
|
|
@ -65,13 +74,64 @@ def test_scan_trouve_le_materiel_par_code_barre(client, session):
|
|||
assert "Câble RJ45 2m" in reponse.text
|
||||
|
||||
|
||||
def test_scan_code_inconnu_affiche_une_erreur(client, session):
|
||||
def test_scan_code_inconnu_propose_la_creation(client, session):
|
||||
"""Un code-barre sans matériel correspondant doit proposer le
|
||||
formulaire de création rapide, pas juste un message d'erreur."""
|
||||
_creer_sous_categorie(session)
|
||||
|
||||
reponse = client.post("/scan", data={"code_barre": "0000000000000"})
|
||||
|
||||
assert reponse.status_code == 200
|
||||
assert "Aucun matériel" in reponse.text
|
||||
assert "0000000000000" in reponse.text
|
||||
assert 'name="categorie_id"' in reponse.text
|
||||
|
||||
|
||||
def test_creation_depuis_le_scan(client, session):
|
||||
sous_categorie = _creer_sous_categorie(session)
|
||||
|
||||
reponse = client.post(
|
||||
"/scan/creer",
|
||||
data={
|
||||
"nom": "Clavier sans fil",
|
||||
"code_barre": "5551112223334",
|
||||
"categorie_id": sous_categorie.id,
|
||||
"quantite": "4",
|
||||
},
|
||||
)
|
||||
|
||||
assert reponse.status_code == 200
|
||||
assert "Clavier sans fil" in reponse.text
|
||||
materiel = session.exec(select(Materiel).where(Materiel.code_barre == "5551112223334")).first()
|
||||
assert materiel is not None
|
||||
assert materiel.quantite == 4
|
||||
|
||||
|
||||
def test_ajuster_quantite_avec_un_delta_choisi(client, session):
|
||||
sous_categorie = _creer_sous_categorie(session)
|
||||
materiel = Materiel(nom="Écran 24 pouces", categorie_id=sous_categorie.id, quantite=10)
|
||||
session.add(materiel)
|
||||
session.commit()
|
||||
session.refresh(materiel)
|
||||
|
||||
reponse = client.post(f"/scan/{materiel.id}/ajuster", data={"quantite": "4", "sens": "retirer"})
|
||||
|
||||
assert reponse.status_code == 200
|
||||
session.refresh(materiel)
|
||||
assert materiel.quantite == 6
|
||||
|
||||
|
||||
def test_definir_quantite_directement(client, session):
|
||||
sous_categorie = _creer_sous_categorie(session)
|
||||
materiel = Materiel(nom="Écran 24 pouces", categorie_id=sous_categorie.id, quantite=10)
|
||||
session.add(materiel)
|
||||
session.commit()
|
||||
session.refresh(materiel)
|
||||
|
||||
reponse = client.post(f"/scan/{materiel.id}/definir", data={"quantite": "17"})
|
||||
|
||||
assert reponse.status_code == 200
|
||||
session.refresh(materiel)
|
||||
assert materiel.quantite == 17
|
||||
|
||||
|
||||
def test_alerte_declenchee_sous_le_seuil(client, session, capsys):
|
||||
|
|
@ -88,7 +148,7 @@ def test_alerte_declenchee_sous_le_seuil(client, session, capsys):
|
|||
session.add(DestinataireAlerte(email="it-dept@clinique.local"))
|
||||
session.commit()
|
||||
|
||||
reponse = client.post(f"/scan/{materiel.id}/ajuster", data={"delta": "-1"})
|
||||
reponse = client.post(f"/scan/{materiel.id}/ajuster", data={"quantite": "1", "sens": "retirer"})
|
||||
|
||||
assert reponse.status_code == 200
|
||||
sortie = capsys.readouterr().out
|
||||
|
|
@ -110,7 +170,8 @@ def test_pas_alerte_si_stock_suffisant(client, session, capsys):
|
|||
session.add(DestinataireAlerte(email="it-dept@clinique.local"))
|
||||
session.commit()
|
||||
|
||||
client.post(f"/scan/{materiel.id}/ajuster", data={"delta": "-1"})
|
||||
reponse = client.post(f"/scan/{materiel.id}/ajuster", data={"quantite": "1", "sens": "retirer"})
|
||||
|
||||
assert reponse.status_code == 200
|
||||
sortie = capsys.readouterr().out
|
||||
assert "EMAIL SIMULÉ" not in sortie
|
||||
|
|
|
|||
Loading…
Reference in a new issue