Le filtre fournisseur était une recherche de sous-chaîne : impossible de comparer deux fournisseurs, et « PPR » ramenait ses homonymes au passage. Il devient une sélection multiple exacte, prise dans un menu déroulant filtrable au clavier (SelectionMultiple, générique et réutilisable). La liste vient de /api/fournisseurs, remise en ordre alphabétique — l'API la trie par montant, ce qui se lit bien dans un classement mais rend introuvable un fournisseur qu'on cherche à l'œil. Côté API, `fournisseur` devient répétable et s'entend comme un OU exact. Le filtre tag, lui, ne savait pas demander « ce qui n'est pas encore tagué » — c'est pourtant la question qui amorce le travail de tagging. `tag_id=0` le demande, et l'option « Sans tag » l'ouvre depuis la page. Au passage, les deux endpoints dupliquaient leurs filtres : ils partagent désormais _appliquer_filtres, pour que les totaux du résumé ne puissent plus porter sur d'autres lignes que la table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
480 lines
16 KiB
Python
480 lines
16 KiB
Python
"""Analytics routes - Data analysis and reporting endpoints."""
|
|
|
|
from collections import defaultdict
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import distinct, func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ...database import get_session
|
|
from ...database.models import Depense, Document, Immeuble, Lot, Tag
|
|
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
|
|
from ..schemas.models import (
|
|
CategorySummary,
|
|
DepenseDetail,
|
|
DepensesSummary,
|
|
FournisseurResponse,
|
|
FournisseurSummary,
|
|
ImmeubleResponse,
|
|
LotResponse,
|
|
MonthlySummary,
|
|
TagResponse,
|
|
TagSummary,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api", tags=["analytics"])
|
|
|
|
#: Valeur de `tag_id` demandant les depenses sans tag. C'est la meme clef que
|
|
#: celle employee pour agreger les non taggues dans `by_tag` : « aucun tag » est
|
|
#: un choix de filtre a part entiere, pas l'absence de filtre (`tag_id` omis).
|
|
SANS_TAG = 0
|
|
|
|
|
|
def _appliquer_filtres(
|
|
stmt,
|
|
*,
|
|
immeuble_id: int | None,
|
|
lot_id: int | None,
|
|
tag_id: int | None,
|
|
categorie: str | None,
|
|
fournisseurs: list[str] | None,
|
|
date_debut: date | None,
|
|
date_fin: date | None,
|
|
):
|
|
"""Applique les filtres communs a la table et au resume.
|
|
|
|
Les deux endpoints doivent voir exactement le meme perimetre : les totaux du
|
|
resume ne veulent rien dire s'ils portent sur d'autres lignes que la table.
|
|
"""
|
|
if immeuble_id is not None:
|
|
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
|
|
if lot_id is not None:
|
|
stmt = stmt.where(Depense.lot_id == lot_id)
|
|
if tag_id is not None:
|
|
if tag_id == SANS_TAG:
|
|
stmt = stmt.where(Depense.tag_id.is_(None))
|
|
else:
|
|
stmt = stmt.where(Depense.tag_id == tag_id)
|
|
if categorie is not None:
|
|
stmt = stmt.where(Depense.categorie == categorie)
|
|
if fournisseurs:
|
|
stmt = stmt.where(Depense.fournisseur.in_(fournisseurs))
|
|
if date_debut is not None:
|
|
stmt = stmt.where(Document.date >= date_debut)
|
|
if date_fin is not None:
|
|
stmt = stmt.where(Document.date <= date_fin)
|
|
return stmt
|
|
|
|
|
|
# ============================================================
|
|
# Reference data endpoints (for filters)
|
|
# ============================================================
|
|
|
|
|
|
@router.get("/immeubles", response_model=list[ImmeubleResponse])
|
|
async def list_immeubles(
|
|
session: Session = Depends(get_session),
|
|
) -> list[ImmeubleResponse]:
|
|
"""Liste tous les immeubles avec statistiques.
|
|
|
|
Retourne la liste des immeubles avec le nombre de lots et de depenses.
|
|
"""
|
|
# Get immeubles with counts
|
|
stmt = (
|
|
select(
|
|
Immeuble,
|
|
func.count(distinct(Lot.id)).label("nb_lots"),
|
|
func.count(distinct(Depense.id)).label("nb_depenses"),
|
|
)
|
|
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
|
|
.outerjoin(Depense, Depense.immeuble_id == Immeuble.id)
|
|
.group_by(Immeuble.id)
|
|
.order_by(Immeuble.code)
|
|
)
|
|
|
|
result = session.execute(stmt)
|
|
rows = result.all()
|
|
|
|
return [
|
|
ImmeubleResponse(
|
|
id=row.Immeuble.id,
|
|
code=row.Immeuble.code,
|
|
denomination=row.Immeuble.denomination,
|
|
adresse=row.Immeuble.adresse,
|
|
ville=row.Immeuble.ville,
|
|
code_postal=row.Immeuble.code_postal,
|
|
nb_lots=row.nb_lots or 0,
|
|
nb_depenses=row.nb_depenses or 0,
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
|
|
@router.get("/lots", response_model=list[LotResponse])
|
|
async def list_lots(
|
|
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
|
session: Session = Depends(get_session),
|
|
) -> list[LotResponse]:
|
|
"""Liste tous les lots, optionnellement filtres par immeuble.
|
|
|
|
- **immeuble_id**: ID de l'immeuble pour filtrer (optionnel)
|
|
"""
|
|
stmt = joindre_fiche(
|
|
select(
|
|
Lot.id,
|
|
Lot.numero,
|
|
Lot.immeuble_id,
|
|
TYPE_LOT_EFFECTIF.label("type"),
|
|
Immeuble.code.label("immeuble_code"),
|
|
).join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
|
).order_by(Immeuble.code, Lot.numero)
|
|
|
|
if immeuble_id is not None:
|
|
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
|
|
|
result = session.execute(stmt)
|
|
rows = result.all()
|
|
|
|
return [
|
|
LotResponse(
|
|
id=row.id,
|
|
numero=row.numero,
|
|
type=row.type,
|
|
immeuble_id=row.immeuble_id,
|
|
immeuble_code=row.immeuble_code,
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
|
|
@router.get("/fournisseurs", response_model=list[FournisseurResponse])
|
|
async def list_fournisseurs(
|
|
session: Session = Depends(get_session),
|
|
) -> list[FournisseurResponse]:
|
|
"""Liste tous les fournisseurs distincts avec statistiques.
|
|
|
|
Retourne la liste des fournisseurs avec le nombre de depenses et total.
|
|
"""
|
|
stmt = (
|
|
select(
|
|
Depense.fournisseur,
|
|
func.count(Depense.id).label("nb_depenses"),
|
|
func.sum(Depense.debit).label("total_debit"),
|
|
)
|
|
.where(Depense.fournisseur.isnot(None))
|
|
.where(Depense.fournisseur != "")
|
|
.group_by(Depense.fournisseur)
|
|
.order_by(func.sum(Depense.debit).desc())
|
|
)
|
|
|
|
result = session.execute(stmt)
|
|
rows = result.all()
|
|
|
|
return [
|
|
FournisseurResponse(
|
|
nom=row.fournisseur,
|
|
nb_depenses=row.nb_depenses or 0,
|
|
total_debit=row.total_debit or 0.0,
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
|
|
@router.get("/tags/stats", response_model=list[TagResponse])
|
|
async def list_tags_with_stats(
|
|
session: Session = Depends(get_session),
|
|
) -> list[TagResponse]:
|
|
"""Liste tous les tags avec statistiques.
|
|
|
|
Retourne la liste des tags avec le nombre de depenses associees.
|
|
"""
|
|
stmt = (
|
|
select(
|
|
Tag,
|
|
func.count(Depense.id).label("nb_depenses"),
|
|
)
|
|
.outerjoin(Depense, Depense.tag_id == Tag.id)
|
|
.group_by(Tag.id)
|
|
.order_by(Tag.nom)
|
|
)
|
|
|
|
result = session.execute(stmt)
|
|
rows = result.all()
|
|
|
|
return [
|
|
TagResponse(
|
|
id=row.Tag.id,
|
|
nom=row.Tag.nom,
|
|
nb_depenses=row.nb_depenses or 0,
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
|
|
# ============================================================
|
|
# Analytics endpoints
|
|
# ============================================================
|
|
|
|
|
|
@router.get("/analytics/depenses", response_model=list[DepenseDetail])
|
|
async def get_depenses(
|
|
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
|
lot_id: int | None = Query(None, description="Filtrer par lot"),
|
|
tag_id: int | None = Query(None, description="Filtrer par tag (0 = sans tag)"),
|
|
categorie: str | None = Query(None, description="Filtrer par categorie"),
|
|
fournisseur: list[str] | None = Query(
|
|
None, description="Fournisseurs retenus (parametre repetable)"
|
|
),
|
|
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
|
|
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
|
|
limit: int = Query(500, description="Nombre maximum de resultats"),
|
|
offset: int = Query(0, description="Decalage pour la pagination"),
|
|
session: Session = Depends(get_session),
|
|
) -> list[DepenseDetail]:
|
|
"""Retourne la liste des depenses filtrees avec details.
|
|
|
|
Filtres disponibles:
|
|
- **immeuble_id**: ID de l'immeuble
|
|
- **lot_id**: ID du lot
|
|
- **tag_id**: ID du tag, ou 0 pour les depenses sans tag
|
|
- **categorie**: Categorie de depense
|
|
- **fournisseur**: nom exact d'un fournisseur, repetable pour en retenir
|
|
plusieurs (`?fournisseur=A&fournisseur=B`)
|
|
- **date_debut**: Date de debut (incluse)
|
|
- **date_fin**: Date de fin (incluse)
|
|
- **limit**: Nombre max de resultats (defaut: 500)
|
|
- **offset**: Decalage pour pagination
|
|
"""
|
|
stmt = (
|
|
select(
|
|
Depense,
|
|
Document.date.label("document_date"),
|
|
Document.reference.label("document_reference"),
|
|
Immeuble.code.label("immeuble_code"),
|
|
Immeuble.adresse.label("immeuble_adresse"),
|
|
Lot.numero.label("lot_numero"),
|
|
Tag.nom.label("tag_nom"),
|
|
)
|
|
.join(Document, Depense.document_id == Document.id)
|
|
.join(Immeuble, Depense.immeuble_id == Immeuble.id)
|
|
.outerjoin(Lot, Depense.lot_id == Lot.id)
|
|
.outerjoin(Tag, Depense.tag_id == Tag.id)
|
|
.order_by(Document.date.desc(), Depense.id.desc())
|
|
)
|
|
|
|
stmt = _appliquer_filtres(
|
|
stmt,
|
|
immeuble_id=immeuble_id,
|
|
lot_id=lot_id,
|
|
tag_id=tag_id,
|
|
categorie=categorie,
|
|
fournisseurs=fournisseur,
|
|
date_debut=date_debut,
|
|
date_fin=date_fin,
|
|
)
|
|
|
|
stmt = stmt.limit(limit).offset(offset)
|
|
|
|
result = session.execute(stmt)
|
|
rows = result.all()
|
|
|
|
return [
|
|
DepenseDetail(
|
|
id=row.Depense.id,
|
|
document_id=row.Depense.document_id,
|
|
document_date=row.document_date,
|
|
document_reference=row.document_reference,
|
|
immeuble_id=row.Depense.immeuble_id,
|
|
immeuble_code=row.immeuble_code,
|
|
immeuble_adresse=row.immeuble_adresse,
|
|
lot_id=row.Depense.lot_id,
|
|
lot_numero=row.lot_numero,
|
|
tag_id=row.Depense.tag_id,
|
|
tag_nom=row.tag_nom,
|
|
categorie=row.Depense.categorie,
|
|
sous_categorie=row.Depense.sous_categorie,
|
|
fournisseur=row.Depense.fournisseur,
|
|
description=row.Depense.description,
|
|
debit=row.Depense.debit or 0.0,
|
|
credit=row.Depense.credit or 0.0,
|
|
tva=row.Depense.tva or 0.0,
|
|
locatif=row.Depense.locatif or 0.0,
|
|
deductible=row.Depense.deductible or 0.0,
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
|
|
@router.get("/analytics/depenses/summary", response_model=DepensesSummary)
|
|
async def get_depenses_summary(
|
|
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
|
lot_id: int | None = Query(None, description="Filtrer par lot"),
|
|
tag_id: int | None = Query(None, description="Filtrer par tag (0 = sans tag)"),
|
|
categorie: str | None = Query(None, description="Filtrer par categorie"),
|
|
fournisseur: list[str] | None = Query(
|
|
None, description="Fournisseurs retenus (parametre repetable)"
|
|
),
|
|
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
|
|
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
|
|
session: Session = Depends(get_session),
|
|
) -> DepensesSummary:
|
|
"""Retourne un resume agrege des depenses pour les graphiques.
|
|
|
|
Memes filtres que /analytics/depenses.
|
|
Retourne:
|
|
- Totaux globaux
|
|
- Repartition par categorie
|
|
- Repartition par tag
|
|
- Evolution mensuelle
|
|
- Top fournisseurs
|
|
"""
|
|
# Base query with filters
|
|
base_stmt = _appliquer_filtres(
|
|
select(Depense).join(Document, Depense.document_id == Document.id),
|
|
immeuble_id=immeuble_id,
|
|
lot_id=lot_id,
|
|
tag_id=tag_id,
|
|
categorie=categorie,
|
|
fournisseurs=fournisseur,
|
|
date_debut=date_debut,
|
|
date_fin=date_fin,
|
|
)
|
|
|
|
# Get all matching depenses
|
|
result = session.execute(base_stmt)
|
|
depenses = result.scalars().all()
|
|
|
|
# Calculate totals
|
|
total_count = len(depenses)
|
|
total_debit = sum(d.debit or 0 for d in depenses)
|
|
total_credit = sum(d.credit or 0 for d in depenses)
|
|
total_tva = sum(d.tva or 0 for d in depenses)
|
|
total_locatif = sum(d.locatif or 0 for d in depenses)
|
|
total_deductible = sum(d.deductible or 0 for d in depenses)
|
|
|
|
# Get document dates for monthly aggregation
|
|
doc_dates = {}
|
|
for d in depenses:
|
|
if d.document_id not in doc_dates:
|
|
doc = session.get(Document, d.document_id)
|
|
doc_dates[d.document_id] = doc.date if doc else None
|
|
|
|
# Aggregate by category
|
|
by_category_dict = defaultdict(
|
|
lambda: {
|
|
"count": 0,
|
|
"debit": 0.0,
|
|
"credit": 0.0,
|
|
"tva": 0.0,
|
|
"locatif": 0.0,
|
|
"deductible": 0.0,
|
|
}
|
|
)
|
|
for d in depenses:
|
|
cat = d.categorie or "NON_CATEGORISE"
|
|
by_category_dict[cat]["count"] += 1
|
|
by_category_dict[cat]["debit"] += d.debit or 0
|
|
by_category_dict[cat]["credit"] += d.credit or 0
|
|
by_category_dict[cat]["tva"] += d.tva or 0
|
|
by_category_dict[cat]["locatif"] += d.locatif or 0
|
|
by_category_dict[cat]["deductible"] += d.deductible or 0
|
|
|
|
by_category = [
|
|
CategorySummary(
|
|
categorie=cat,
|
|
count=data["count"],
|
|
total_debit=data["debit"],
|
|
total_credit=data["credit"],
|
|
total_tva=data["tva"],
|
|
total_locatif=data["locatif"],
|
|
total_deductible=data["deductible"],
|
|
)
|
|
for cat, data in sorted(by_category_dict.items(), key=lambda x: -x[1]["debit"])
|
|
]
|
|
|
|
# Aggregate by tag
|
|
by_tag_dict = defaultdict(lambda: {"tag_nom": None, "count": 0, "debit": 0.0})
|
|
for d in depenses:
|
|
tag_key = d.tag_id or 0 # 0 for untagged
|
|
by_tag_dict[tag_key]["count"] += 1
|
|
by_tag_dict[tag_key]["debit"] += d.debit or 0
|
|
if d.tag_id and d.tag:
|
|
by_tag_dict[tag_key]["tag_nom"] = d.tag.nom
|
|
|
|
by_tag = [
|
|
TagSummary(
|
|
tag_id=tag_id if tag_id != 0 else None,
|
|
tag_nom=data["tag_nom"] if tag_id != 0 else "Non taggue",
|
|
count=data["count"],
|
|
total_debit=data["debit"],
|
|
)
|
|
for tag_id, data in sorted(by_tag_dict.items(), key=lambda x: -x[1]["debit"])
|
|
]
|
|
|
|
# Aggregate by month
|
|
by_month_dict = defaultdict(lambda: {"count": 0, "debit": 0.0, "credit": 0.0})
|
|
for d in depenses:
|
|
doc_date = doc_dates.get(d.document_id)
|
|
if doc_date:
|
|
month_key = (doc_date.year, doc_date.month)
|
|
by_month_dict[month_key]["count"] += 1
|
|
by_month_dict[month_key]["debit"] += d.debit or 0
|
|
by_month_dict[month_key]["credit"] += d.credit or 0
|
|
|
|
by_month = [
|
|
MonthlySummary(
|
|
year=year,
|
|
month=month,
|
|
count=data["count"],
|
|
total_debit=data["debit"],
|
|
total_credit=data["credit"],
|
|
)
|
|
for (year, month), data in sorted(by_month_dict.items())
|
|
]
|
|
|
|
# Aggregate by fournisseur (top 20)
|
|
by_fournisseur_dict = defaultdict(lambda: {"count": 0, "debit": 0.0})
|
|
for d in depenses:
|
|
fournisseur_key = d.fournisseur or "Non specifie"
|
|
by_fournisseur_dict[fournisseur_key]["count"] += 1
|
|
by_fournisseur_dict[fournisseur_key]["debit"] += d.debit or 0
|
|
|
|
by_fournisseur = [
|
|
FournisseurSummary(
|
|
fournisseur=fournisseur if fournisseur != "Non specifie" else None,
|
|
count=data["count"],
|
|
total_debit=data["debit"],
|
|
)
|
|
for fournisseur, data in sorted(
|
|
by_fournisseur_dict.items(), key=lambda x: -x[1]["debit"]
|
|
)[:20]
|
|
]
|
|
|
|
return DepensesSummary(
|
|
total_count=total_count,
|
|
total_debit=total_debit,
|
|
total_credit=total_credit,
|
|
total_tva=total_tva,
|
|
total_locatif=total_locatif,
|
|
total_deductible=total_deductible,
|
|
by_category=by_category,
|
|
by_tag=by_tag,
|
|
by_month=by_month,
|
|
by_fournisseur=by_fournisseur,
|
|
)
|
|
|
|
|
|
@router.get("/analytics/categories")
|
|
async def list_categories(
|
|
session: Session = Depends(get_session),
|
|
) -> list[str]:
|
|
"""Liste toutes les categories de depenses distinctes."""
|
|
stmt = (
|
|
select(distinct(Depense.categorie))
|
|
.where(Depense.categorie.isnot(None))
|
|
.order_by(Depense.categorie)
|
|
)
|
|
result = session.execute(stmt)
|
|
return [row[0] for row in result.all() if row[0]]
|