Compare commits
8 Commits
41107c984c
...
11bd3ca539
| Author | SHA1 | Date | |
|---|---|---|---|
| 11bd3ca539 | |||
| 3e2d103929 | |||
| 1c817a4ac9 | |||
| 5a132b1258 | |||
| 49e060e6e4 | |||
| ea5bdac18a | |||
| 1b319dff4e | |||
| 2ac7855cd5 |
@@ -220,7 +220,7 @@ const editInput = ref(null)
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
const resp = await fetch(`${API}/api/config/tags`)
|
||||
const resp = await fetch(`${API}/api/tags`)
|
||||
tags.value = await resp.json()
|
||||
} catch (e) {
|
||||
console.error('Failed to load tags', e)
|
||||
@@ -248,7 +248,7 @@ async function confirmRenameTag(tagId) {
|
||||
if (!nom) return
|
||||
tagMessage.value = ''
|
||||
try {
|
||||
const resp = await fetch(`${API}/api/config/tags/${tagId}`, {
|
||||
const resp = await fetch(`${API}/api/tags/${tagId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nom }),
|
||||
@@ -274,7 +274,7 @@ async function createTag() {
|
||||
if (!nom) return
|
||||
tagMessage.value = ''
|
||||
try {
|
||||
const resp = await fetch(`${API}/api/config/tags`, {
|
||||
const resp = await fetch(`${API}/api/tags`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nom }),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import mimetypes
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -87,6 +87,13 @@ if FRONTEND_DIST.exists():
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def serve_spa(full_path: str):
|
||||
"""Serve the SPA for all non-API routes."""
|
||||
# Une URL d'API inconnue doit se dire inconnue. Sans cette garde, elle
|
||||
# tomberait dans le catch-all et repartirait en index.html avec un 200 :
|
||||
# côté client, une faute de frappe dans une URL ne ressemblerait plus à
|
||||
# une erreur mais à une réponse vide.
|
||||
if full_path == "api" or full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="Endpoint inconnu")
|
||||
|
||||
# If requesting a file that exists (and stays within dist), serve it
|
||||
file_path = (FRONTEND_DIST / full_path).resolve()
|
||||
if file_path.is_file() and file_path.is_relative_to(_dist_root):
|
||||
|
||||
@@ -4,8 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import DatabaseService, get_session
|
||||
from ...database.models import Tag
|
||||
from ...database import get_session
|
||||
from ...services.settings_service import (
|
||||
SETTINGS_REGISTRY,
|
||||
delete_setting,
|
||||
@@ -25,14 +24,6 @@ class SettingUpdate(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
nom: str
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
nom: str
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Settings endpoints
|
||||
# ============================================================
|
||||
@@ -71,64 +62,3 @@ async def reset_setting(
|
||||
# Return the resolved value after deletion
|
||||
all_settings = get_all_settings(session)
|
||||
return all_settings[key]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tags endpoints
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
async def list_tags(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[dict]:
|
||||
"""Liste tous les tags."""
|
||||
db_service = DatabaseService(session)
|
||||
tags = db_service.list_tags()
|
||||
return [{"id": tag.id, "nom": tag.nom} for tag in tags]
|
||||
|
||||
|
||||
@router.post("/tags", status_code=201)
|
||||
async def create_tag(
|
||||
body: TagCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Crée un nouveau tag (validation unicité)."""
|
||||
nom = body.nom.strip()
|
||||
if not nom:
|
||||
raise HTTPException(status_code=400, detail="Le nom du tag ne peut pas être vide.")
|
||||
|
||||
existing = session.query(Tag).filter(Tag.nom == nom).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
|
||||
|
||||
tag = Tag(nom=nom)
|
||||
session.add(tag)
|
||||
session.commit()
|
||||
session.refresh(tag)
|
||||
return {"id": tag.id, "nom": tag.nom}
|
||||
|
||||
|
||||
@router.put("/tags/{tag_id}")
|
||||
async def rename_tag(
|
||||
tag_id: int,
|
||||
body: TagUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Renomme un tag (validation unicité)."""
|
||||
nom = body.nom.strip()
|
||||
if not nom:
|
||||
raise HTTPException(status_code=400, detail="Le nom du tag ne peut pas être vide.")
|
||||
|
||||
tag = session.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag introuvable.")
|
||||
|
||||
existing = session.query(Tag).filter(Tag.nom == nom, Tag.id != tag_id).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
|
||||
|
||||
tag.nom = nom
|
||||
session.commit()
|
||||
session.refresh(tag)
|
||||
return {"id": tag.id, "nom": tag.nom}
|
||||
|
||||
@@ -17,6 +17,7 @@ from ...database.models import (
|
||||
Lot,
|
||||
Revenu,
|
||||
)
|
||||
from ...services.revenus_query import est_flux, flux_par, restant_du_par
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
@@ -85,53 +86,11 @@ class ImmeubleShortcutResponse(BaseModel):
|
||||
total_impayes: float
|
||||
|
||||
|
||||
class DashboardStatsResponse(BaseModel):
|
||||
"""Stats enrichies pour le dashboard."""
|
||||
|
||||
documents: int
|
||||
immeubles: int
|
||||
lots: int
|
||||
locataires: int
|
||||
total_revenus: float
|
||||
total_depenses: float
|
||||
total_impayes: float
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Endpoints
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStatsResponse)
|
||||
async def get_dashboard_stats(
|
||||
session: Session = Depends(get_session),
|
||||
) -> DashboardStatsResponse:
|
||||
"""Retourne les statistiques enrichies pour le dashboard.
|
||||
|
||||
Inclut les compteurs et les totaux financiers.
|
||||
"""
|
||||
# Compteurs
|
||||
documents_count = session.execute(select(func.count(Document.id))).scalar() or 0
|
||||
immeubles_count = session.execute(select(func.count(Immeuble.id))).scalar() or 0
|
||||
lots_count = session.execute(select(func.count(Lot.id))).scalar() or 0
|
||||
locataires_count = session.execute(select(func.count(Locataire.id))).scalar() or 0
|
||||
|
||||
# Totaux financiers
|
||||
total_revenus = session.execute(select(func.sum(Revenu.total))).scalar() or 0.0
|
||||
total_depenses = session.execute(select(func.sum(Depense.debit))).scalar() or 0.0
|
||||
total_impayes = session.execute(select(func.sum(Revenu.impayes))).scalar() or 0.0
|
||||
|
||||
return DashboardStatsResponse(
|
||||
documents=documents_count,
|
||||
immeubles=immeubles_count,
|
||||
lots=lots_count,
|
||||
locataires=locataires_count,
|
||||
total_revenus=total_revenus,
|
||||
total_depenses=total_depenses,
|
||||
total_impayes=total_impayes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/financial-summary", response_model=FinancialSummaryResponse)
|
||||
async def get_financial_summary(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -156,10 +115,13 @@ async def get_financial_summary(
|
||||
last_document_date = str(last_doc.date)
|
||||
last_document_reference = last_doc.reference
|
||||
|
||||
# Revenus du dernier document
|
||||
# Revenus du dernier document : le solde reporte du mois precedent n'est
|
||||
# pas un revenu du mois, il est deja compte dans les impayes.
|
||||
revenus = (
|
||||
session.execute(
|
||||
select(func.sum(Revenu.total)).where(Revenu.document_id == last_doc.id)
|
||||
select(func.sum(Revenu.total))
|
||||
.where(Revenu.document_id == last_doc.id)
|
||||
.where(est_flux())
|
||||
).scalar()
|
||||
or 0.0
|
||||
)
|
||||
@@ -195,11 +157,13 @@ async def get_financial_summary(
|
||||
impayes_by_month: dict[str, float] = defaultdict(float)
|
||||
depenses_by_month: dict[str, float] = defaultdict(float)
|
||||
|
||||
# Recuperer revenus et impayes par mois
|
||||
# Recuperer revenus et impayes par mois. Le revenu du mois exclut le report
|
||||
# pour que les mois soient comparables ; l'impaye reste le solde constate ce
|
||||
# mois-la, de sorte que la courbe suive la dette au lieu de l'empiler.
|
||||
revenus_stmt = (
|
||||
select(
|
||||
Document.date,
|
||||
func.sum(Revenu.total).label("total"),
|
||||
func.sum(Revenu.total).filter(est_flux()).label("total"),
|
||||
func.sum(Revenu.impayes).label("impayes"),
|
||||
)
|
||||
.join(Revenu, Revenu.document_id == Document.id)
|
||||
@@ -319,9 +283,10 @@ async def get_monthly_trends(
|
||||
today = date.today()
|
||||
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
|
||||
|
||||
# Recuperer tous les revenus depuis start_date
|
||||
# Recuperer tous les revenus depuis start_date, report exclu : la courbe
|
||||
# compare des mois entre eux, pas des soldes cumules.
|
||||
revenus_stmt = (
|
||||
select(Document.date, func.sum(Revenu.total).label("total"))
|
||||
select(Document.date, func.sum(Revenu.total).filter(est_flux()).label("total"))
|
||||
.join(Revenu, Revenu.document_id == Document.id)
|
||||
.where(Document.date >= start_date)
|
||||
.group_by(Document.date)
|
||||
@@ -375,51 +340,55 @@ async def get_immeubles_shortcuts(
|
||||
|
||||
- **limit**: Nombre maximum d'immeubles (defaut: 5)
|
||||
"""
|
||||
# Requete pour les immeubles avec stats
|
||||
# Effectifs et activite, puis flux et restant du : trois granularites
|
||||
# differentes, jointes plutot que melangees pour ne pas se multiplier.
|
||||
effectifs = (
|
||||
select(
|
||||
Lot.immeuble_id.label("immeuble_id"),
|
||||
func.count(func.distinct(Lot.id)).label("nb_lots"),
|
||||
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
|
||||
)
|
||||
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
|
||||
.group_by(Lot.immeuble_id)
|
||||
.subquery()
|
||||
)
|
||||
activite = (
|
||||
select(
|
||||
Document.immeuble_id.label("immeuble_id"),
|
||||
func.count(Document.id).label("nb_documents"),
|
||||
)
|
||||
.group_by(Document.immeuble_id)
|
||||
.subquery()
|
||||
)
|
||||
flux = flux_par(Document.immeuble_id)
|
||||
stock = restant_du_par(Document.immeuble_id)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Immeuble,
|
||||
func.count(func.distinct(Lot.id)).label("nb_lots"),
|
||||
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
|
||||
func.count(func.distinct(Document.id)).label("nb_documents"),
|
||||
effectifs.c.nb_lots,
|
||||
effectifs.c.nb_locataires,
|
||||
flux.c.facture,
|
||||
stock.c.restant_du,
|
||||
)
|
||||
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
|
||||
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
|
||||
.outerjoin(Document, Document.immeuble_id == Immeuble.id)
|
||||
.group_by(Immeuble.id)
|
||||
.order_by(desc("nb_documents"))
|
||||
.outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id)
|
||||
.outerjoin(activite, activite.c.immeuble_id == Immeuble.id)
|
||||
.outerjoin(flux, flux.c.cle == Immeuble.id)
|
||||
.outerjoin(stock, stock.c.cle == Immeuble.id)
|
||||
.order_by(desc(activite.c.nb_documents))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = session.execute(stmt)
|
||||
immeubles = result.all()
|
||||
|
||||
# Pour chaque immeuble, recuperer les totaux revenus/impayes
|
||||
shortcuts = []
|
||||
for row in immeubles:
|
||||
immeuble = row.Immeuble
|
||||
|
||||
# Revenus de cet immeuble
|
||||
revenus_stmt = (
|
||||
select(func.sum(Revenu.total), func.sum(Revenu.impayes))
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.where(Lot.immeuble_id == immeuble.id)
|
||||
)
|
||||
rev_result = session.execute(revenus_stmt).first()
|
||||
total_revenus = rev_result[0] or 0.0 if rev_result else 0.0
|
||||
total_impayes = rev_result[1] or 0.0 if rev_result else 0.0
|
||||
|
||||
shortcuts.append(
|
||||
return [
|
||||
ImmeubleShortcutResponse(
|
||||
id=immeuble.id,
|
||||
code=immeuble.code,
|
||||
adresse=immeuble.adresse,
|
||||
ville=immeuble.ville,
|
||||
id=row.Immeuble.id,
|
||||
code=row.Immeuble.code,
|
||||
adresse=row.Immeuble.adresse,
|
||||
ville=row.Immeuble.ville,
|
||||
nb_lots=row.nb_lots or 0,
|
||||
nb_locataires=row.nb_locataires or 0,
|
||||
total_revenus=total_revenus,
|
||||
total_impayes=total_impayes,
|
||||
total_revenus=row.facture or 0.0,
|
||||
total_impayes=row.restant_du or 0.0,
|
||||
)
|
||||
)
|
||||
|
||||
return shortcuts
|
||||
for row in session.execute(stmt)
|
||||
]
|
||||
|
||||
@@ -6,11 +6,9 @@ from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import DatabaseService, get_session, storage
|
||||
from ...database.models import Depense, Document, Immeuble, Locataire, Lot, Revenu
|
||||
from ...database.service import DuplicateDocumentError
|
||||
from ...extractor import extract_compte_rendu
|
||||
from ...utils.canonical import canonical_copy
|
||||
@@ -149,24 +147,6 @@ async def save_document_with_pdf(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Retourne les statistiques globales de la base de donnees.
|
||||
|
||||
Compteurs pour chaque table principale.
|
||||
"""
|
||||
return {
|
||||
"documents": session.execute(select(func.count(Document.id))).scalar() or 0,
|
||||
"immeubles": session.execute(select(func.count(Immeuble.id))).scalar() or 0,
|
||||
"lots": session.execute(select(func.count(Lot.id))).scalar() or 0,
|
||||
"locataires": session.execute(select(func.count(Locataire.id))).scalar() or 0,
|
||||
"revenus": session.execute(select(func.count(Revenu.id))).scalar() or 0,
|
||||
"depenses": session.execute(select(func.count(Depense.id))).scalar() or 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/documents", response_model=list[DocumentSummary])
|
||||
async def list_documents(
|
||||
limit: int = 100,
|
||||
|
||||
@@ -15,6 +15,12 @@ from ...database.models import (
|
||||
Lot,
|
||||
Revenu,
|
||||
)
|
||||
from ...services.revenus_query import (
|
||||
est_flux,
|
||||
flux_par,
|
||||
restant_du_par,
|
||||
taux_de_recouvrement,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/revenus", tags=["revenus"])
|
||||
|
||||
@@ -127,6 +133,70 @@ class RevenusSummaryResponse(BaseModel):
|
||||
top_impayes: list[RevenuByLocataire]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Agregats par immeuble
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _effectifs_par_immeuble():
|
||||
"""Sous-requete : nombre de lots et de locataires par immeuble."""
|
||||
return (
|
||||
select(
|
||||
Lot.immeuble_id.label("immeuble_id"),
|
||||
func.count(func.distinct(Lot.id)).label("nb_lots"),
|
||||
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
|
||||
)
|
||||
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
|
||||
.group_by(Lot.immeuble_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def _immeuble_response(row) -> RevenuByImmeuble:
|
||||
"""Construit la reponse d'un immeuble a partir d'une ligne agregee."""
|
||||
return RevenuByImmeuble(
|
||||
immeuble_id=row.id,
|
||||
immeuble_code=row.code,
|
||||
adresse=row.adresse,
|
||||
ville=row.ville,
|
||||
nb_lots=row.nb_lots or 0,
|
||||
nb_locataires=row.nb_locataires or 0,
|
||||
total_revenus=row.facture or 0.0,
|
||||
total_regles=row.encaisse or 0.0,
|
||||
total_impayes=row.restant_du or 0.0,
|
||||
taux_recouvrement=taux_de_recouvrement(row.facture, row.facture_regle),
|
||||
)
|
||||
|
||||
|
||||
def _immeuble_stmt(date_debut: date | None = None):
|
||||
"""Requete des immeubles avec leurs flux, leur restant du et leurs effectifs.
|
||||
|
||||
Les trois sous-requetes sont jointes plutot que calculees d'un bloc : chacune
|
||||
a sa propre granularite (une ligne par revenu, par lot, par locataire) et les
|
||||
melanger multiplierait les lignes entre elles.
|
||||
"""
|
||||
flux = flux_par(Document.immeuble_id, date_debut)
|
||||
stock = restant_du_par(Document.immeuble_id, date_debut)
|
||||
effectifs = _effectifs_par_immeuble()
|
||||
return (
|
||||
select(
|
||||
Immeuble.id,
|
||||
Immeuble.code,
|
||||
Immeuble.adresse,
|
||||
Immeuble.ville,
|
||||
effectifs.c.nb_lots,
|
||||
effectifs.c.nb_locataires,
|
||||
flux.c.facture,
|
||||
flux.c.encaisse,
|
||||
flux.c.facture_regle,
|
||||
stock.c.restant_du,
|
||||
)
|
||||
.outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id)
|
||||
.outerjoin(flux, flux.c.cle == Immeuble.id)
|
||||
.outerjoin(stock, stock.c.cle == Immeuble.id)
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Endpoints
|
||||
# ============================================================
|
||||
@@ -143,35 +213,32 @@ async def get_revenus_summary(
|
||||
Inclut les KPIs, l'evolution mensuelle, la repartition par immeuble
|
||||
et les locataires avec le plus d'impayes.
|
||||
"""
|
||||
# Base filters
|
||||
filters = []
|
||||
if immeuble_id:
|
||||
filters.append(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
# Calculate date range
|
||||
today = date.today()
|
||||
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
|
||||
|
||||
# ========== KPIs ==========
|
||||
kpi_stmt = select(
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.loyers).label("total_loyers"),
|
||||
func.sum(Revenu.taxes).label("total_taxes"),
|
||||
func.sum(Revenu.provisions).label("total_provisions"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
).join(Lot, Revenu.lot_id == Lot.id)
|
||||
# Les montants factures se cumulent sur la periode ; le restant du est lu
|
||||
# dans le dernier compte rendu, sans quoi une meme dette serait recomptee a
|
||||
# chaque document et un remboursement ne s'y verrait jamais.
|
||||
flux = flux_par(Document.immeuble_id, start_date)
|
||||
stock = restant_du_par(Document.immeuble_id, start_date)
|
||||
|
||||
if filters:
|
||||
kpi_stmt = kpi_stmt.where(and_(*filters))
|
||||
|
||||
kpi_result = session.execute(kpi_stmt).first()
|
||||
|
||||
total_revenus = kpi_result.total_revenus or 0.0
|
||||
total_regles = kpi_result.total_regles or 0.0
|
||||
taux_recouvrement = (
|
||||
(total_regles / total_revenus * 100) if total_revenus > 0 else 100.0
|
||||
flux_stmt = select(
|
||||
func.sum(flux.c.loyers).label("loyers"),
|
||||
func.sum(flux.c.taxes).label("taxes"),
|
||||
func.sum(flux.c.provisions).label("provisions"),
|
||||
func.sum(flux.c.facture).label("facture"),
|
||||
func.sum(flux.c.encaisse).label("encaisse"),
|
||||
func.sum(flux.c.facture_regle).label("facture_regle"),
|
||||
)
|
||||
stock_stmt = select(func.sum(stock.c.restant_du))
|
||||
if immeuble_id:
|
||||
flux_stmt = flux_stmt.where(flux.c.cle == immeuble_id)
|
||||
stock_stmt = stock_stmt.where(stock.c.cle == immeuble_id)
|
||||
|
||||
totaux = session.execute(flux_stmt).first()
|
||||
restant_du = session.execute(stock_stmt).scalar() or 0.0
|
||||
|
||||
# Count active locataires and occupied lots
|
||||
locataires_stmt = (
|
||||
@@ -194,41 +261,43 @@ async def get_revenus_summary(
|
||||
nb_lots = session.execute(lots_stmt).scalar() or 0
|
||||
|
||||
kpis = RevenuKpiResponse(
|
||||
total_revenus=total_revenus,
|
||||
total_loyers=kpi_result.total_loyers or 0.0,
|
||||
total_taxes=kpi_result.total_taxes or 0.0,
|
||||
total_provisions=kpi_result.total_provisions or 0.0,
|
||||
total_regles=total_regles,
|
||||
total_impayes=kpi_result.total_impayes or 0.0,
|
||||
taux_recouvrement=round(taux_recouvrement, 1),
|
||||
total_revenus=totaux.facture or 0.0,
|
||||
total_loyers=totaux.loyers or 0.0,
|
||||
total_taxes=totaux.taxes or 0.0,
|
||||
total_provisions=totaux.provisions or 0.0,
|
||||
total_regles=totaux.encaisse or 0.0,
|
||||
total_impayes=restant_du,
|
||||
taux_recouvrement=taux_de_recouvrement(totaux.facture, totaux.facture_regle),
|
||||
nb_locataires_actifs=nb_locataires,
|
||||
nb_lots_occupes=nb_lots,
|
||||
)
|
||||
|
||||
# ========== Monthly evolution ==========
|
||||
# Les montants du mois sont des flux, report exclu, pour que deux mois
|
||||
# soient comparables. L'impaye, lui, reste le solde constate ce mois-la :
|
||||
# la courbe montre l'evolution de la dette, pas son accumulation.
|
||||
mois = func.strftime("%Y-%m", Document.date)
|
||||
flux_mois = est_flux()
|
||||
monthly_stmt = (
|
||||
select(
|
||||
func.strftime("%Y-%m", Document.date).label("month"),
|
||||
func.sum(Revenu.loyers).label("loyers"),
|
||||
func.sum(Revenu.taxes).label("taxes"),
|
||||
func.sum(Revenu.provisions).label("provisions"),
|
||||
func.sum(Revenu.total).label("total"),
|
||||
mois.label("month"),
|
||||
func.sum(Revenu.loyers).filter(flux_mois).label("loyers"),
|
||||
func.sum(Revenu.taxes).filter(flux_mois).label("taxes"),
|
||||
func.sum(Revenu.provisions).filter(flux_mois).label("provisions"),
|
||||
func.sum(Revenu.total).filter(flux_mois).label("total"),
|
||||
func.sum(Revenu.regles).label("regles"),
|
||||
func.sum(Revenu.impayes).label("impayes"),
|
||||
)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.where(Document.date >= start_date)
|
||||
.group_by(func.strftime("%Y-%m", Document.date))
|
||||
.order_by(func.strftime("%Y-%m", Document.date))
|
||||
.group_by(mois)
|
||||
.order_by(mois)
|
||||
)
|
||||
|
||||
if immeuble_id:
|
||||
monthly_stmt = monthly_stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
monthly_stmt = monthly_stmt.where(Document.immeuble_id == immeuble_id)
|
||||
|
||||
monthly_data = []
|
||||
for row in session.execute(monthly_stmt):
|
||||
monthly_data.append(
|
||||
monthly_data = [
|
||||
RevenuMonthlyPoint(
|
||||
month=row.month,
|
||||
loyers=row.loyers or 0.0,
|
||||
@@ -238,52 +307,27 @@ async def get_revenus_summary(
|
||||
regles=row.regles or 0.0,
|
||||
impayes=row.impayes or 0.0,
|
||||
)
|
||||
)
|
||||
for row in session.execute(monthly_stmt)
|
||||
]
|
||||
|
||||
# ========== By Immeuble ==========
|
||||
immeuble_stmt = (
|
||||
select(
|
||||
Immeuble.id,
|
||||
Immeuble.code,
|
||||
Immeuble.adresse,
|
||||
Immeuble.ville,
|
||||
func.count(func.distinct(Lot.id)).label("nb_lots"),
|
||||
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
)
|
||||
.join(Lot, Lot.immeuble_id == Immeuble.id)
|
||||
.join(Revenu, Revenu.lot_id == Lot.id)
|
||||
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
|
||||
.group_by(Immeuble.id)
|
||||
.order_by(desc("total_revenus"))
|
||||
)
|
||||
|
||||
# Seuls les immeubles ayant produit des revenus sur la periode y figurent.
|
||||
immeuble_stmt = _immeuble_stmt(start_date).order_by(desc("facture"))
|
||||
if immeuble_id:
|
||||
immeuble_stmt = immeuble_stmt.where(Immeuble.id == immeuble_id)
|
||||
|
||||
by_immeuble = []
|
||||
for row in session.execute(immeuble_stmt):
|
||||
rev = row.total_revenus or 0.0
|
||||
reg = row.total_regles or 0.0
|
||||
taux = (reg / rev * 100) if rev > 0 else 100.0
|
||||
by_immeuble.append(
|
||||
RevenuByImmeuble(
|
||||
immeuble_id=row.id,
|
||||
immeuble_code=row.code,
|
||||
adresse=row.adresse,
|
||||
ville=row.ville,
|
||||
nb_lots=row.nb_lots or 0,
|
||||
nb_locataires=row.nb_locataires or 0,
|
||||
total_revenus=rev,
|
||||
total_regles=reg,
|
||||
total_impayes=row.total_impayes or 0.0,
|
||||
taux_recouvrement=round(taux, 1),
|
||||
)
|
||||
)
|
||||
by_immeuble = [
|
||||
_immeuble_response(row)
|
||||
for row in session.execute(immeuble_stmt)
|
||||
if row.facture is not None
|
||||
]
|
||||
|
||||
# ========== Top impayes by locataire ==========
|
||||
# Le classement porte sur la dette encore due au dernier compte rendu. Sur
|
||||
# un cumul, un locataire ayant solde son retard resterait affiche comme
|
||||
# debiteur indefiniment : le remboursement ne s'y inscrivait jamais.
|
||||
dette = restant_du_par(Revenu.locataire_id, start_date)
|
||||
flux_locataire = flux_par(Revenu.locataire_id, start_date)
|
||||
impayes_stmt = (
|
||||
select(
|
||||
Locataire.id,
|
||||
@@ -292,26 +336,27 @@ async def get_revenus_summary(
|
||||
Lot.numero,
|
||||
Immeuble.code,
|
||||
Immeuble.adresse,
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
func.count(Revenu.id).label("nb_mois"),
|
||||
flux_locataire.c.facture,
|
||||
flux_locataire.c.encaisse,
|
||||
dette.c.restant_du,
|
||||
func.count(func.distinct(Revenu.document_id)).label("nb_mois"),
|
||||
)
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.join(Locataire, Revenu.locataire_id == Locataire.id)
|
||||
.select_from(Locataire)
|
||||
.join(dette, dette.c.cle == Locataire.id)
|
||||
.join(Lot, Locataire.lot_id == Lot.id)
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
.outerjoin(flux_locataire, flux_locataire.c.cle == Locataire.id)
|
||||
.outerjoin(Revenu, Revenu.locataire_id == Locataire.id)
|
||||
.where(dette.c.restant_du > 0)
|
||||
.group_by(Locataire.id)
|
||||
.having(func.sum(Revenu.impayes) > 0)
|
||||
.order_by(desc("total_impayes"))
|
||||
.order_by(desc("restant_du"))
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
if immeuble_id:
|
||||
impayes_stmt = impayes_stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
top_impayes = []
|
||||
for row in session.execute(impayes_stmt):
|
||||
top_impayes.append(
|
||||
top_impayes = [
|
||||
RevenuByLocataire(
|
||||
locataire_id=row.id,
|
||||
locataire_nom=row.nom,
|
||||
@@ -319,12 +364,13 @@ async def get_revenus_summary(
|
||||
immeuble_code=row.code,
|
||||
immeuble_adresse=row.adresse,
|
||||
date_debut=str(row.date_debut) if row.date_debut else None,
|
||||
total_revenus=row.total_revenus or 0.0,
|
||||
total_regles=row.total_regles or 0.0,
|
||||
total_impayes=row.total_impayes or 0.0,
|
||||
total_revenus=row.facture or 0.0,
|
||||
total_regles=row.encaisse or 0.0,
|
||||
total_impayes=row.restant_du or 0.0,
|
||||
nb_mois=row.nb_mois or 0,
|
||||
)
|
||||
)
|
||||
for row in session.execute(impayes_stmt)
|
||||
]
|
||||
|
||||
return RevenusSummaryResponse(
|
||||
kpis=kpis,
|
||||
@@ -341,6 +387,8 @@ async def get_revenus_by_lot(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[RevenuByLot]:
|
||||
"""Retourne les revenus agreges par lot."""
|
||||
flux = flux_par(Revenu.lot_id)
|
||||
dette = restant_du_par(Revenu.lot_id)
|
||||
stmt = (
|
||||
select(
|
||||
Lot.id,
|
||||
@@ -348,9 +396,9 @@ async def get_revenus_by_lot(
|
||||
Lot.type,
|
||||
Immeuble.code,
|
||||
func.max(Locataire.nom).label("locataire_nom"),
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
flux.c.facture,
|
||||
flux.c.encaisse,
|
||||
dette.c.restant_du,
|
||||
func.max(Document.date).label("derniere_date"),
|
||||
)
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
@@ -360,31 +408,30 @@ async def get_revenus_by_lot(
|
||||
Locataire,
|
||||
and_(Locataire.lot_id == Lot.id, Locataire.date_fin.is_(None)),
|
||||
)
|
||||
.outerjoin(flux, flux.c.cle == Lot.id)
|
||||
.outerjoin(dette, dette.c.cle == Lot.id)
|
||||
.group_by(Lot.id)
|
||||
.order_by(desc("total_impayes"), desc("total_revenus"))
|
||||
.order_by(desc("restant_du"), desc("facture"))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if immeuble_id:
|
||||
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
results = []
|
||||
for row in session.execute(stmt):
|
||||
results.append(
|
||||
return [
|
||||
RevenuByLot(
|
||||
lot_id=row.id,
|
||||
lot_numero=row.numero,
|
||||
lot_type=row.type,
|
||||
immeuble_code=row.code,
|
||||
locataire_nom=row.locataire_nom,
|
||||
total_revenus=row.total_revenus or 0.0,
|
||||
total_regles=row.total_regles or 0.0,
|
||||
total_impayes=row.total_impayes or 0.0,
|
||||
total_revenus=row.facture or 0.0,
|
||||
total_regles=row.encaisse or 0.0,
|
||||
total_impayes=row.restant_du or 0.0,
|
||||
derniere_date=str(row.derniere_date) if row.derniere_date else None,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
for row in session.execute(stmt)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/details", response_model=list[RevenuDetailResponse])
|
||||
@@ -471,43 +518,7 @@ async def get_immeubles_with_revenus(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[RevenuByImmeuble]:
|
||||
"""Retourne la liste des immeubles avec leurs stats de revenus."""
|
||||
stmt = (
|
||||
select(
|
||||
Immeuble.id,
|
||||
Immeuble.code,
|
||||
Immeuble.adresse,
|
||||
Immeuble.ville,
|
||||
func.count(func.distinct(Lot.id)).label("nb_lots"),
|
||||
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
)
|
||||
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
|
||||
.outerjoin(Revenu, Revenu.lot_id == Lot.id)
|
||||
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
|
||||
.group_by(Immeuble.id)
|
||||
.order_by(Immeuble.code)
|
||||
)
|
||||
# Tous les immeubles sont listes, y compris ceux sans aucun revenu.
|
||||
stmt = _immeuble_stmt().order_by(Immeuble.code)
|
||||
|
||||
results = []
|
||||
for row in session.execute(stmt):
|
||||
rev = row.total_revenus or 0.0
|
||||
reg = row.total_regles or 0.0
|
||||
taux = (reg / rev * 100) if rev > 0 else 100.0
|
||||
results.append(
|
||||
RevenuByImmeuble(
|
||||
immeuble_id=row.id,
|
||||
immeuble_code=row.code,
|
||||
adresse=row.adresse,
|
||||
ville=row.ville,
|
||||
nb_lots=row.nb_lots or 0,
|
||||
nb_locataires=row.nb_locataires or 0,
|
||||
total_revenus=rev,
|
||||
total_regles=reg,
|
||||
total_impayes=row.total_impayes or 0.0,
|
||||
taux_recouvrement=round(taux, 1),
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
return [_immeuble_response(row) for row in session.execute(stmt)]
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
"""Tags routes - Tag management and prediction."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import DatabaseService, get_session
|
||||
from ...database.models import Tag
|
||||
from ...services.tag_predictor import TagPredictor
|
||||
from ..schemas import PredictTagsRequest
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["tags"])
|
||||
|
||||
|
||||
class TagBody(BaseModel):
|
||||
"""Corps de requete pour creer ou renommer un tag."""
|
||||
|
||||
nom: str
|
||||
|
||||
|
||||
def _tag_dict(tag: Tag) -> dict:
|
||||
return {"id": tag.id, "nom": tag.nom}
|
||||
|
||||
|
||||
def _nom_valide(nom: str) -> str:
|
||||
nom = nom.strip()
|
||||
if not nom:
|
||||
raise HTTPException(status_code=400, detail="Le nom du tag ne peut pas être vide.")
|
||||
return nom
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
async def list_tags(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -21,7 +40,47 @@ async def list_tags(
|
||||
db_service = DatabaseService(session)
|
||||
tags = db_service.list_tags()
|
||||
|
||||
return [{"id": tag.id, "nom": tag.nom} for tag in tags]
|
||||
return [_tag_dict(tag) for tag in tags]
|
||||
|
||||
|
||||
@router.post("/tags", status_code=201)
|
||||
async def create_tag(
|
||||
body: TagBody,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Cree un nouveau tag (validation unicite)."""
|
||||
nom = _nom_valide(body.nom)
|
||||
|
||||
if session.query(Tag).filter(Tag.nom == nom).first():
|
||||
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
|
||||
|
||||
tag = Tag(nom=nom)
|
||||
session.add(tag)
|
||||
session.commit()
|
||||
session.refresh(tag)
|
||||
return _tag_dict(tag)
|
||||
|
||||
|
||||
@router.put("/tags/{tag_id}")
|
||||
async def rename_tag(
|
||||
tag_id: int,
|
||||
body: TagBody,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Renomme un tag (validation unicite)."""
|
||||
nom = _nom_valide(body.nom)
|
||||
|
||||
tag = session.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag introuvable.")
|
||||
|
||||
if session.query(Tag).filter(Tag.nom == nom, Tag.id != tag_id).first():
|
||||
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
|
||||
|
||||
tag.nom = nom
|
||||
session.commit()
|
||||
session.refresh(tag)
|
||||
return _tag_dict(tag)
|
||||
|
||||
|
||||
@router.post("/predict-tags")
|
||||
|
||||
@@ -403,77 +403,8 @@ class DatabaseService:
|
||||
if depense.tag_id is not None
|
||||
]
|
||||
|
||||
def get_revenus_summary(
|
||||
self, immeuble_id: int = None, year: int = None
|
||||
) -> list[dict]:
|
||||
"""Get revenue summary grouped by period."""
|
||||
stmt = select(Revenu)
|
||||
|
||||
if immeuble_id:
|
||||
stmt = stmt.join(Lot).where(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
if year:
|
||||
stmt = stmt.where(
|
||||
Revenu.periode_debut >= date(year, 1, 1),
|
||||
Revenu.periode_debut <= date(year, 12, 31),
|
||||
)
|
||||
|
||||
result = self.session.execute(stmt)
|
||||
revenus = result.scalars().all()
|
||||
|
||||
# Aggregate
|
||||
total_loyers = sum(r.loyers for r in revenus)
|
||||
total_regles = sum(r.regles for r in revenus)
|
||||
total_impayes = sum(r.impayes for r in revenus)
|
||||
|
||||
return {
|
||||
"total_loyers": total_loyers,
|
||||
"total_regles": total_regles,
|
||||
"total_impayes": total_impayes,
|
||||
"count": len(revenus),
|
||||
}
|
||||
|
||||
def get_depenses_summary(self, immeuble_id: int = None, year: int = None) -> dict:
|
||||
"""Get expenses summary grouped by category."""
|
||||
stmt = select(Depense)
|
||||
|
||||
if immeuble_id:
|
||||
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
|
||||
|
||||
if year:
|
||||
stmt = stmt.join(Document).where(
|
||||
Document.date >= date(year, 1, 1), Document.date <= date(year, 12, 31)
|
||||
)
|
||||
|
||||
result = self.session.execute(stmt)
|
||||
depenses = result.scalars().all()
|
||||
|
||||
# Aggregate by category
|
||||
by_category = {}
|
||||
for d in depenses:
|
||||
cat = d.categorie or "AUTRE"
|
||||
if cat not in by_category:
|
||||
by_category[cat] = {"debit": 0.0, "credit": 0.0, "count": 0}
|
||||
by_category[cat]["debit"] += d.debit
|
||||
by_category[cat]["credit"] += d.credit
|
||||
by_category[cat]["count"] += 1
|
||||
|
||||
total_debit = sum(d.debit for d in depenses)
|
||||
total_credit = sum(d.credit for d in depenses)
|
||||
|
||||
return {
|
||||
"by_category": by_category,
|
||||
"total_debit": total_debit,
|
||||
"total_credit": total_credit,
|
||||
"count": len(depenses),
|
||||
}
|
||||
|
||||
def list_tags(self) -> list[Tag]:
|
||||
"""List all available tags."""
|
||||
stmt = select(Tag).order_by(Tag.nom)
|
||||
result = self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
def get_tag_by_id(self, tag_id: int) -> Tag | None:
|
||||
"""Get a tag by ID."""
|
||||
return self.session.get(Tag, tag_id)
|
||||
|
||||
@@ -189,19 +189,6 @@ def get_absolute_path(relative_path: str, storage_root: Path | None = None) -> P
|
||||
return storage_root / relative_path
|
||||
|
||||
|
||||
def file_exists(relative_path: str, storage_root: Path | None = None) -> bool:
|
||||
"""Check if a file exists in storage.
|
||||
|
||||
Args:
|
||||
relative_path: Relative path stored in database.
|
||||
storage_root: Optional custom storage root.
|
||||
|
||||
Returns:
|
||||
True if file exists.
|
||||
"""
|
||||
return get_absolute_path(relative_path, storage_root).exists()
|
||||
|
||||
|
||||
def read_pdf(relative_path: str, storage_root: Path | None = None) -> bytes:
|
||||
"""Read PDF content from storage.
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"""Orchestrateur principal pour l'extraction des comptes rendus de gérance."""
|
||||
|
||||
import logging
|
||||
|
||||
from .parsers.locataires import extract_situation_locataires
|
||||
from .parsers.locataires_table import extract_situation_locataires_from_pdf
|
||||
from .parsers.metadata import extract_metadata
|
||||
from .parsers.operations import extract_recapitulatif_operations
|
||||
from .parsers.operations_table import extract_recapitulatif_operations_from_pdf
|
||||
from .parsers.pdf import read_pdf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ExtractionError(Exception):
|
||||
"""Levée quand un PDF ne livre pas les données attendues d'un compte rendu."""
|
||||
|
||||
|
||||
def extract_compte_rendu(pdf_path: str) -> dict:
|
||||
@@ -29,28 +27,23 @@ def extract_compte_rendu(pdf_path: str) -> dict:
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Si le fichier PDF n'existe pas
|
||||
ExtractionError: Si aucun lot n'a pu être lu
|
||||
"""
|
||||
content = read_pdf(pdf_path)
|
||||
|
||||
# Extraction des locataires par cellules de tableau (géométrique) : robuste aux
|
||||
# colonnes vides et aux lignes mal alignées. Repli sur l'ancien parseur texte si
|
||||
# le tableau n'a pas de filets détectables ou en cas d'erreur inattendue.
|
||||
try:
|
||||
# Un compte rendu sans aucun lot n'existe pas : c'est le signe que le tableau
|
||||
# n'a pas été reconnu (PDF d'un autre type, mise en page inconnue, scan
|
||||
# image). Échouer ici vaut mieux qu'enregistrer un document vide, qui ne se
|
||||
# découvrirait qu'au moment de relire les chiffres.
|
||||
situation = extract_situation_locataires_from_pdf(pdf_path)
|
||||
except Exception:
|
||||
logger.exception("Extraction locataires par cellules échouée, repli sur le parseur texte")
|
||||
situation = []
|
||||
if not situation:
|
||||
situation = extract_situation_locataires(content.text)
|
||||
raise ExtractionError(
|
||||
"Aucun lot n'a pu être lu dans ce PDF : le tableau « situation des "
|
||||
"locataires » est absent ou dans un format non reconnu."
|
||||
)
|
||||
|
||||
# Opérations par cellules de tableau, même repli sur le parseur texte.
|
||||
try:
|
||||
# À l'inverse, un mois sans aucune dépense reste plausible : liste vide admise.
|
||||
operations = extract_recapitulatif_operations_from_pdf(pdf_path)
|
||||
except Exception:
|
||||
logger.exception("Extraction opérations par cellules échouée, repli sur le parseur texte")
|
||||
operations = []
|
||||
if not operations:
|
||||
operations = extract_recapitulatif_operations(content.text)
|
||||
|
||||
return {
|
||||
"metadata": extract_metadata(content.text, content.words),
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Parsers pour les différentes sections des PDFs de gérance."""
|
||||
|
||||
from .locataires import extract_situation_locataires
|
||||
from .locataires_table import extract_situation_locataires_from_pdf
|
||||
from .metadata import extract_metadata
|
||||
from .operations import extract_recapitulatif_operations
|
||||
from .pdf import PdfContent, extract_text_from_pdf, read_pdf
|
||||
from .operations_table import extract_recapitulatif_operations_from_pdf
|
||||
from .pdf import PdfContent, read_pdf
|
||||
|
||||
__all__ = [
|
||||
"PdfContent",
|
||||
"read_pdf",
|
||||
"extract_text_from_pdf",
|
||||
"extract_metadata",
|
||||
"extract_situation_locataires",
|
||||
"extract_recapitulatif_operations",
|
||||
"extract_situation_locataires_from_pdf",
|
||||
"extract_recapitulatif_operations_from_pdf",
|
||||
]
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
"""Extraction de la situation des locataires."""
|
||||
|
||||
import re
|
||||
|
||||
from ..utils.amounts import extract_amounts_from_line
|
||||
from ..utils.dates import parse_french_date
|
||||
from ..utils.lots import normalize_lot_numero
|
||||
|
||||
|
||||
def _preprocess_locataires_text(text: str) -> str:
|
||||
"""Prétraite le texte pour fusionner les sections sur plusieurs pages.
|
||||
|
||||
Supprime les éléments répétés sur chaque page pour permettre une extraction
|
||||
continue des locataires dont les données s'étalent sur plusieurs pages.
|
||||
|
||||
Args:
|
||||
text: Texte brut du PDF
|
||||
|
||||
Returns:
|
||||
Texte nettoyé avec une seule section SITUATION DES LOCATAIRES
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
cleaned_lines = []
|
||||
first_situation_found = False
|
||||
|
||||
# Patterns à ignorer (en-têtes répétés sur chaque page)
|
||||
skip_patterns = [
|
||||
r"^\s*ROSIER-MODICA\s*$",
|
||||
r"^\s*9 rue Juliette Récamier\s*$",
|
||||
r"^\s*69455 Lyon Cedex 06\s*$",
|
||||
r"^\s*S\.C\.I\.\s*PLESNA\s*$",
|
||||
r"^\s*Immeuble\s*:\s*\d+\s*$",
|
||||
r"^\s*\d+\s*RUE\s+", # Adresse immeuble
|
||||
r"^\s*69\d{3}\s+LYON\s*$", # Code postal + ville
|
||||
r"^\s*Lyon le \d{2}/\d{2}/\d{4}\s*$", # Date
|
||||
r"^\s*Powered by ICS\s*$",
|
||||
r"^\s*\d+\s*/\s*\d+\s*$", # Numéro de page (ex: 2/5)
|
||||
r"^\s*Capital de", # Pied de page
|
||||
r"^\s*Garantie de", # Pied de page
|
||||
]
|
||||
|
||||
# Pattern pour l'en-tête de colonnes
|
||||
header_pattern = r"^\s*Locataires\s+Période\s+Loyers"
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
# Ignorer les lignes vides
|
||||
if not stripped:
|
||||
cleaned_lines.append(line)
|
||||
continue
|
||||
|
||||
# Vérifier si c'est un pattern à ignorer
|
||||
should_skip = False
|
||||
for pattern in skip_patterns:
|
||||
if re.match(pattern, stripped, re.IGNORECASE):
|
||||
should_skip = True
|
||||
break
|
||||
|
||||
if should_skip:
|
||||
continue
|
||||
|
||||
# Gérer SITUATION DES LOCATAIRES
|
||||
if "SITUATION DES LOCATAIRES" in stripped:
|
||||
if not first_situation_found:
|
||||
first_situation_found = True
|
||||
cleaned_lines.append(line)
|
||||
# Ignorer les occurrences suivantes
|
||||
continue
|
||||
|
||||
# Ignorer les en-têtes de colonnes répétés
|
||||
if re.match(header_pattern, stripped):
|
||||
continue
|
||||
|
||||
cleaned_lines.append(line)
|
||||
|
||||
return "\n".join(cleaned_lines)
|
||||
|
||||
|
||||
def extract_situation_locataires(text: str) -> list[dict]:
|
||||
"""Extrait la situation des locataires.
|
||||
|
||||
Args:
|
||||
text: Texte complet du PDF
|
||||
|
||||
Returns:
|
||||
Liste des situations par lot, chacune contenant:
|
||||
- lot: numéro (2 chiffres) et type
|
||||
- locataire: nom
|
||||
- lignes: détail des loyers, charges, etc.
|
||||
- totaux: sommes par catégorie
|
||||
"""
|
||||
situations: list[dict] = []
|
||||
|
||||
# Prétraiter le texte pour gérer les lots sur plusieurs pages
|
||||
text = _preprocess_locataires_text(text)
|
||||
|
||||
# Trouver toutes les sections "SITUATION DES LOCATAIRES"
|
||||
sections = text.split("SITUATION DES LOCATAIRES")
|
||||
|
||||
for section in sections[1:]: # Skip avant le premier titre
|
||||
# Couper à la fin de la section
|
||||
section = section.split("RECAPITULATIF")[0]
|
||||
section = section.split("VOTRE PATRIMOINE")[0]
|
||||
|
||||
lines = section.split("\n")
|
||||
current_lot: dict | None = None
|
||||
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
if not line_stripped:
|
||||
continue
|
||||
|
||||
# Nouveau lot (peut avoir les données de loyer sur la même ligne)
|
||||
lot_match = re.match(
|
||||
r"Lot\s+(\d{1,4})\s+(Loc\.\s*Commercial|Appartement\s+T\d|Studio|Garage|Cave|Parking)",
|
||||
line_stripped,
|
||||
)
|
||||
if lot_match:
|
||||
if current_lot:
|
||||
situations.append(current_lot)
|
||||
|
||||
lot_num = normalize_lot_numero(lot_match.group(1))
|
||||
lot_type = lot_match.group(2)
|
||||
|
||||
current_lot = {
|
||||
"lot": {"numero": lot_num, "type": lot_type},
|
||||
"locataire": {"nom": ""},
|
||||
"lignes": [],
|
||||
"totaux": {
|
||||
"solde_anterieur": 0.0,
|
||||
"loyers": 0.0,
|
||||
"taxes": 0.0,
|
||||
"provisions": 0.0,
|
||||
"divers": 0.0,
|
||||
"total": 0.0,
|
||||
"regles": 0.0,
|
||||
"impayes": 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
# Chercher le reste de la ligne après le type de lot
|
||||
remaining = line_stripped[lot_match.end() :].strip()
|
||||
|
||||
# Vérifier si il y a une période de loyer sur la même ligne
|
||||
loyer_inline = re.search(
|
||||
r"Du\s+(\d{2}\.\d{2}\.\d{2})\s+Au\s+(\d{2}\.\d{2}\.\d{2})",
|
||||
remaining,
|
||||
)
|
||||
|
||||
if loyer_inline:
|
||||
# Le nom du locataire sera sur la ligne suivante
|
||||
# Extraire la ligne de loyer
|
||||
debut = parse_french_date(loyer_inline.group(1))
|
||||
fin = parse_french_date(loyer_inline.group(2))
|
||||
amounts = extract_amounts_from_line(remaining)
|
||||
|
||||
ligne = {
|
||||
"type": "loyer",
|
||||
"periode": {"debut": debut, "fin": fin},
|
||||
"loyers": amounts[0] if len(amounts) > 0 else 0.0,
|
||||
"taxes": amounts[1] if len(amounts) > 1 else 0.0,
|
||||
"provisions": amounts[2] if len(amounts) > 2 else 0.0,
|
||||
"divers": {"montant": 0.0, "libelle": None},
|
||||
"total": amounts[3] if len(amounts) > 3 else 0.0,
|
||||
"regles": amounts[4] if len(amounts) > 4 else 0.0,
|
||||
"impayes": amounts[5] if len(amounts) > 5 else 0.0,
|
||||
}
|
||||
current_lot["lignes"].append(ligne)
|
||||
else:
|
||||
# Chercher le nom du locataire (avant Du ou avant les espaces multiples)
|
||||
name_match = re.match(
|
||||
r"([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][A-Za-zàâäéèêëïîôùûüç\-\s]+?)(?:\s{2,}|$)",
|
||||
remaining,
|
||||
)
|
||||
if name_match:
|
||||
current_lot["locataire"]["nom"] = name_match.group(1).strip()
|
||||
continue
|
||||
|
||||
if not current_lot:
|
||||
continue
|
||||
|
||||
# Mise à jour du nom si trouvé sur ligne séparée
|
||||
if not current_lot["locataire"]["nom"]:
|
||||
# Exclure les faux positifs
|
||||
excluded = [
|
||||
"Solde",
|
||||
"Du ",
|
||||
"Totaux",
|
||||
"Powered by",
|
||||
"SITUATION",
|
||||
"RECAPITULATIF",
|
||||
"Locataires",
|
||||
"Période",
|
||||
"Rappel",
|
||||
]
|
||||
if not any(x in line_stripped for x in excluded):
|
||||
name_match = re.match(
|
||||
r"^([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][A-Za-zàâäéèêëïîôùûüç\-\s]+?)(?:\s{2,}|$)",
|
||||
line_stripped,
|
||||
)
|
||||
if name_match:
|
||||
current_lot["locataire"]["nom"] = name_match.group(1).strip()
|
||||
continue
|
||||
|
||||
# Solde Antérieur
|
||||
if "Solde Antérieur" in line_stripped:
|
||||
amounts = extract_amounts_from_line(line_stripped)
|
||||
if amounts:
|
||||
montant = amounts[0]
|
||||
ligne = {
|
||||
"type": "solde_anterieur",
|
||||
"periode": {"debut": None, "fin": None},
|
||||
"loyers": montant,
|
||||
"taxes": 0.0,
|
||||
"provisions": 0.0,
|
||||
"divers": {"montant": 0.0, "libelle": None},
|
||||
"total": amounts[1] if len(amounts) > 1 else montant,
|
||||
"regles": amounts[2] if len(amounts) > 2 else 0.0,
|
||||
"impayes": amounts[3] if len(amounts) > 3 else 0.0,
|
||||
}
|
||||
current_lot["lignes"].append(ligne)
|
||||
current_lot["totaux"]["solde_anterieur"] = montant
|
||||
continue
|
||||
|
||||
# Ligne de loyer: Du DD.MM.YY Au DD.MM.YY (sur ligne séparée)
|
||||
loyer_match = re.search(
|
||||
r"Du\s+(\d{2}\.\d{2}\.\d{2})\s+Au\s+(\d{2}\.\d{2}\.\d{2})",
|
||||
line_stripped,
|
||||
)
|
||||
if loyer_match and "Rappel" not in line_stripped:
|
||||
debut = parse_french_date(loyer_match.group(1))
|
||||
fin = parse_french_date(loyer_match.group(2))
|
||||
amounts = extract_amounts_from_line(line_stripped)
|
||||
|
||||
# Chercher un libellé divers
|
||||
divers_patterns = [
|
||||
("Complément", "Complément"),
|
||||
("Ordures", "Ordures ménagères"),
|
||||
("Contrat entretien", "Contrat entretien chaudière"),
|
||||
("Divers locatifs", "Divers locatifs"),
|
||||
]
|
||||
divers_match = None
|
||||
divers_libelle = None
|
||||
for pattern, libelle in divers_patterns:
|
||||
if pattern in line_stripped:
|
||||
divers_match = pattern
|
||||
divers_libelle = libelle
|
||||
break
|
||||
|
||||
# Déterminer si c'est une ligne purement "divers"
|
||||
is_divers_line = False
|
||||
if divers_match:
|
||||
# Trouver la position du libellé divers et du premier montant
|
||||
divers_pos = line_stripped.find(divers_match)
|
||||
# Chercher le premier montant après "Au DD.MM.YY"
|
||||
after_date = line_stripped[loyer_match.end() :]
|
||||
first_amount_match = re.search(r"\d+[,\.]\d{2}", after_date)
|
||||
if first_amount_match:
|
||||
first_amount_pos = (
|
||||
loyer_match.end() + first_amount_match.start()
|
||||
)
|
||||
# Si le libellé divers est AVANT le premier montant, c'est une ligne divers
|
||||
is_divers_line = divers_pos < first_amount_pos
|
||||
|
||||
if is_divers_line:
|
||||
# Ligne de type divers uniquement
|
||||
ligne = {
|
||||
"type": "divers",
|
||||
"periode": {"debut": debut, "fin": fin},
|
||||
"loyers": 0.0,
|
||||
"taxes": 0.0,
|
||||
"provisions": 0.0,
|
||||
"divers": {
|
||||
"montant": amounts[0] if len(amounts) > 0 else 0.0,
|
||||
"libelle": divers_libelle,
|
||||
},
|
||||
"total": amounts[1] if len(amounts) > 1 else 0.0,
|
||||
"regles": amounts[2] if len(amounts) > 2 else 0.0,
|
||||
"impayes": amounts[3] if len(amounts) > 3 else 0.0,
|
||||
}
|
||||
else:
|
||||
# Ligne de loyer normale
|
||||
ligne = {
|
||||
"type": "loyer",
|
||||
"periode": {"debut": debut, "fin": fin},
|
||||
"loyers": amounts[0] if len(amounts) > 0 else 0.0,
|
||||
"taxes": amounts[1] if len(amounts) > 1 else 0.0,
|
||||
"provisions": amounts[2] if len(amounts) > 2 else 0.0,
|
||||
"divers": {"montant": 0.0, "libelle": None},
|
||||
"total": 0.0,
|
||||
"regles": 0.0,
|
||||
"impayes": 0.0,
|
||||
}
|
||||
|
||||
# Vérifier si il y a aussi un divers sur cette ligne (après les montants loyer)
|
||||
if divers_match and len(amounts) > 3:
|
||||
ligne["divers"] = {
|
||||
"montant": amounts[3],
|
||||
"libelle": divers_libelle,
|
||||
}
|
||||
ligne["total"] = amounts[4] if len(amounts) > 4 else 0.0
|
||||
ligne["regles"] = amounts[5] if len(amounts) > 5 else 0.0
|
||||
ligne["impayes"] = amounts[6] if len(amounts) > 6 else 0.0
|
||||
else:
|
||||
ligne["total"] = amounts[3] if len(amounts) > 3 else 0.0
|
||||
ligne["regles"] = amounts[4] if len(amounts) > 4 else 0.0
|
||||
ligne["impayes"] = amounts[5] if len(amounts) > 5 else 0.0
|
||||
|
||||
current_lot["lignes"].append(ligne)
|
||||
continue
|
||||
|
||||
# Rappel de Loyer
|
||||
rappel_match = re.search(
|
||||
r"Rappel de Loyer\s+Du\s+(\d{2}\.\d{2}\.\d{2})\s+Au\s+(\d{2}\.\d{2}\.\d{2})",
|
||||
line_stripped,
|
||||
)
|
||||
if rappel_match:
|
||||
amounts = extract_amounts_from_line(line_stripped)
|
||||
montant = amounts[0] if amounts else 0.0
|
||||
|
||||
current_lot["lignes"].append(
|
||||
{
|
||||
"type": "rappel_loyer",
|
||||
"periode": {
|
||||
"debut": parse_french_date(rappel_match.group(1)),
|
||||
"fin": parse_french_date(rappel_match.group(2)),
|
||||
},
|
||||
"loyers": montant,
|
||||
"taxes": 0.0,
|
||||
"provisions": 0.0,
|
||||
"divers": {"montant": 0.0, "libelle": None},
|
||||
"total": amounts[1] if len(amounts) > 1 else montant,
|
||||
"regles": amounts[2] if len(amounts) > 2 else 0.0,
|
||||
"impayes": amounts[3] if len(amounts) > 3 else 0.0,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Ligne Totaux (pas TOTAUX généraux)
|
||||
if line_stripped.startswith("Totaux") and "TOTAUX" not in line_stripped:
|
||||
amounts = extract_amounts_from_line(line_stripped)
|
||||
|
||||
if len(amounts) >= 6:
|
||||
# Déterminer si le premier est un solde antérieur
|
||||
idx = 0
|
||||
if (
|
||||
current_lot["totaux"]["solde_anterieur"] > 0
|
||||
and len(amounts) >= 7
|
||||
):
|
||||
idx = 1 # Skip le solde antérieur répété
|
||||
|
||||
current_lot["totaux"]["loyers"] = (
|
||||
amounts[idx] if idx < len(amounts) else 0.0
|
||||
)
|
||||
current_lot["totaux"]["taxes"] = (
|
||||
amounts[idx + 1] if idx + 1 < len(amounts) else 0.0
|
||||
)
|
||||
current_lot["totaux"]["provisions"] = (
|
||||
amounts[idx + 2] if idx + 2 < len(amounts) else 0.0
|
||||
)
|
||||
current_lot["totaux"]["divers"] = (
|
||||
amounts[idx + 3] if idx + 3 < len(amounts) else 0.0
|
||||
)
|
||||
current_lot["totaux"]["total"] = (
|
||||
amounts[idx + 4] if idx + 4 < len(amounts) else 0.0
|
||||
)
|
||||
current_lot["totaux"]["regles"] = (
|
||||
amounts[idx + 5] if idx + 5 < len(amounts) else 0.0
|
||||
)
|
||||
if idx + 6 < len(amounts):
|
||||
current_lot["totaux"]["impayes"] = amounts[idx + 6]
|
||||
|
||||
if current_lot:
|
||||
situations.append(current_lot)
|
||||
|
||||
return situations
|
||||
@@ -1,209 +0,0 @@
|
||||
"""Extraction du récapitulatif des opérations."""
|
||||
|
||||
import re
|
||||
|
||||
from ..utils.amounts import parse_amount
|
||||
from ..utils.lots import normalize_lot_numero
|
||||
|
||||
|
||||
def _extract_lot_code_from_description(description: str) -> str | None:
|
||||
"""Extrait le code lot depuis la description de l'opération.
|
||||
|
||||
Les codes lots suivent le format: {Lettre}{Numéro} où:
|
||||
- La lettre identifie l'immeuble (M=Marietton, S=Servient, B=Bloch, etc.)
|
||||
- Le numéro correspond au lot (ex: 06 -> lot 06)
|
||||
|
||||
Exemples:
|
||||
- "M06 - Commande moteur pompe" -> "06"
|
||||
- "S05 - Mise en service" -> "05"
|
||||
- "B01 - Plaques" -> "01"
|
||||
|
||||
Args:
|
||||
description: Description de l'opération
|
||||
|
||||
Returns:
|
||||
Code lot au format 2 chiffres (ex: "06") ou None si non trouvé
|
||||
"""
|
||||
if not description:
|
||||
return None
|
||||
|
||||
# Pattern: lettre majuscule + (espace optionnelle) + 1-2 chiffres, terminé par
|
||||
# un espace, un tiret ou la fin. Gère "S10 - ...", "S 17 - ..." (espace dans le
|
||||
# code) et "S01 SOLDE ..." (code lot non suivi d'un tiret).
|
||||
match = re.search(r"\b[A-Z]\s*(\d{1,2})(?=[\s-]|$)", description)
|
||||
if match:
|
||||
return normalize_lot_numero(match.group(1))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_recapitulatif_operations(text: str) -> list[dict]:
|
||||
"""Extrait le récapitulatif des opérations.
|
||||
|
||||
Args:
|
||||
text: Texte complet du PDF
|
||||
|
||||
Returns:
|
||||
Liste plate des opérations, chacune contenant:
|
||||
- categorie: catégorie normalisée (ex: DEPENSES_LOCATIVES)
|
||||
- sous_categorie: type d'opération (ex: Contrat entreprise nettoyage)
|
||||
- fournisseur: nom du fournisseur
|
||||
- description: description de l'opération
|
||||
- lot_concerne: lot concerné si applicable
|
||||
- montants: dict avec debit, credit, tva, locatif, deductible
|
||||
"""
|
||||
operations: list[dict] = []
|
||||
|
||||
# Catégories à identifier (libellé PDF -> format normalisé)
|
||||
cat_keywords = {
|
||||
"DEPENSES LOCATIVES": "DEPENSES_LOCATIVES",
|
||||
"DEPENSES DEDUCTIBLES": "DEPENSES_DEDUCTIBLES",
|
||||
"DEPENSES NON RECUPERABLES": "DEPENSES_NON_RECUPERABLES",
|
||||
"DEPENSES RECUPERABLES PAR LOT": "DEPENSES_RECUPERABLES",
|
||||
"HONORAIRES DE GESTION": "HONORAIRES_DE_GESTION",
|
||||
"DIVERS": "DIVERS",
|
||||
}
|
||||
|
||||
# Trouver les sections RECAPITULATIF
|
||||
sections = text.split("RECAPITULATIF DES OPERATIONS")
|
||||
|
||||
for section in sections[1:]:
|
||||
section = section.split("VOTRE PATRIMOINE")[0]
|
||||
section = section.split("Solde créditeur en Euros")[0]
|
||||
|
||||
lines = section.split("\n")
|
||||
current_cat_normalized: str | None = None
|
||||
current_fournisseur: str | None = None
|
||||
current_lot: str | None = None
|
||||
current_sous_cat: str | None = None
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
# Ignorer les lignes de totaux et headers
|
||||
if any(
|
||||
x in stripped
|
||||
for x in [
|
||||
"Totaux Généraux",
|
||||
"TOTAL DES REGLEMENTS",
|
||||
"Débits",
|
||||
"Crédits",
|
||||
"Dont T.V.A.",
|
||||
]
|
||||
):
|
||||
continue
|
||||
if stripped.startswith("TOTAUX"):
|
||||
continue
|
||||
|
||||
# Détecter une catégorie
|
||||
found_cat_normalized = None
|
||||
for kw, cat_normalized in cat_keywords.items():
|
||||
if kw in stripped:
|
||||
found_cat_normalized = cat_normalized
|
||||
# Extraire le lot si présent
|
||||
lot_match = re.search(r"(?:LOT|/LOT)\s+(.+?)(?:\s{2,}|$)", stripped)
|
||||
if lot_match:
|
||||
current_lot = lot_match.group(1).strip()
|
||||
else:
|
||||
current_lot = None
|
||||
break
|
||||
|
||||
if found_cat_normalized:
|
||||
current_cat_normalized = found_cat_normalized
|
||||
current_fournisseur = None
|
||||
current_sous_cat = None
|
||||
continue
|
||||
|
||||
if not current_cat_normalized:
|
||||
continue
|
||||
|
||||
# Type d'opération / sous-catégorie (ex: "Contrat entreprise nettoyage")
|
||||
sous_cat_patterns = [
|
||||
"Nettoyage",
|
||||
"Electricité",
|
||||
"Contrat",
|
||||
"Travaux",
|
||||
"Frais",
|
||||
"Honoraires",
|
||||
"TVA",
|
||||
"Plaques",
|
||||
"Curage",
|
||||
"Lavage",
|
||||
"Reglt",
|
||||
]
|
||||
for pattern in sous_cat_patterns:
|
||||
if stripped.startswith(pattern):
|
||||
current_sous_cat = stripped.split(" ")[0].strip()
|
||||
break
|
||||
|
||||
# Fournisseur (NOM EN MAJUSCULES)
|
||||
fournisseur_match = re.match(
|
||||
r"^([A-Z][A-Z\s\-\(\)]+?)(?:\s{2,}|$)", stripped
|
||||
)
|
||||
if fournisseur_match:
|
||||
potential = fournisseur_match.group(1).strip()
|
||||
if len(potential) > 3 and not any(
|
||||
x in potential
|
||||
for x in [
|
||||
"TOTAUX",
|
||||
"DEPENSES",
|
||||
"HONORAIRES",
|
||||
"DIVERS",
|
||||
"TOTAL",
|
||||
"TVA",
|
||||
]
|
||||
):
|
||||
current_fournisseur = potential
|
||||
|
||||
# Extraire les montants (séparés par des espaces à la fin de ligne)
|
||||
# Exclure les années (4 chiffres sans décimale)
|
||||
amounts_pattern = r"(?<!\d)(\d{1,3}(?:[\s\u00a0]?\d{3})*[,\.]\d{2})(?!\d)"
|
||||
amounts = re.findall(amounts_pattern, stripped)
|
||||
|
||||
if amounts and len(amounts) >= 1:
|
||||
amounts_float = [parse_amount(a) for a in amounts]
|
||||
|
||||
# Extraire la description (avant le premier montant)
|
||||
first_amount_match = re.search(amounts_pattern, stripped)
|
||||
if first_amount_match:
|
||||
description = stripped[: first_amount_match.start()].strip()
|
||||
else:
|
||||
description = stripped
|
||||
|
||||
# Nettoyer la description
|
||||
if current_fournisseur and description.startswith(current_fournisseur):
|
||||
description = description[len(current_fournisseur) :].strip()
|
||||
|
||||
# Réduire les espaces multiples (séparateurs de colonnes) en un seul
|
||||
description = re.sub(r"\s{2,}", " ", description).strip()
|
||||
|
||||
if description and not description.startswith("Totaux"):
|
||||
# Extraire le numéro de lot depuis la description (ex: M06 -> 0006)
|
||||
lot_numero = _extract_lot_code_from_description(description)
|
||||
|
||||
operation = {
|
||||
"categorie": current_cat_normalized,
|
||||
"sous_categorie": current_sous_cat or "",
|
||||
"fournisseur": current_fournisseur,
|
||||
"description": description,
|
||||
"lot_concerne": current_lot,
|
||||
"lot_numero": lot_numero,
|
||||
"montants": {
|
||||
"debit": amounts_float[0]
|
||||
if len(amounts_float) > 0
|
||||
else 0.0,
|
||||
"credit": 0.0,
|
||||
"tva": amounts_float[1] if len(amounts_float) > 1 else 0.0,
|
||||
"locatif": amounts_float[2]
|
||||
if len(amounts_float) > 2
|
||||
else 0.0,
|
||||
"deductible": amounts_float[3]
|
||||
if len(amounts_float) > 3
|
||||
else 0.0,
|
||||
},
|
||||
}
|
||||
operations.append(operation)
|
||||
|
||||
return operations
|
||||
@@ -20,7 +20,7 @@ from unicodedata import normalize as _normalize
|
||||
import pdfplumber
|
||||
|
||||
from ..utils.amounts import extract_amounts_from_line
|
||||
from .operations import _extract_lot_code_from_description
|
||||
from ..utils.lots import extract_lot_numero_from_description
|
||||
|
||||
_Y_TOL = 3.0
|
||||
|
||||
@@ -218,7 +218,7 @@ def extract_recapitulatif_operations_from_pdf(pdf_path: str) -> list[dict]:
|
||||
"fournisseur": current_fournisseur,
|
||||
"description": desc,
|
||||
"lot_concerne": None,
|
||||
"lot_numero": _extract_lot_code_from_description(desc),
|
||||
"lot_numero": extract_lot_numero_from_description(desc),
|
||||
"montants": {k: (montants[k] or 0.0) for k in _AMOUNT_KEYS},
|
||||
"_block": block_id,
|
||||
}
|
||||
|
||||
@@ -57,18 +57,3 @@ def read_pdf(pdf_path: str) -> PdfContent:
|
||||
]
|
||||
|
||||
return PdfContent(text="\n".join(text_parts), words=header_words)
|
||||
|
||||
|
||||
def extract_text_from_pdf(pdf_path: str) -> str:
|
||||
"""Extrait le texte du PDF avec mise en page préservée.
|
||||
|
||||
Conserve la signature historique (retourne une chaîne) pour la CLI et
|
||||
les parseurs qui ne consomment que le texte.
|
||||
|
||||
Args:
|
||||
pdf_path: Chemin vers le fichier PDF
|
||||
|
||||
Returns:
|
||||
Texte extrait du PDF avec mise en page préservée
|
||||
"""
|
||||
return read_pdf(pdf_path).text
|
||||
|
||||
138
src/plesna_gerance/services/revenus_query.py
Normal file
138
src/plesna_gerance/services/revenus_query.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Règles d'agrégation des revenus : ce qui se cumule et ce qui ne se cumule pas.
|
||||
|
||||
Deux natures de grandeurs cohabitent dans la table ``revenus``, et les
|
||||
confondre fausse tous les totaux :
|
||||
|
||||
- un **flux** est un événement daté (un loyer facturé en mars, un paiement reçu
|
||||
en avril). Il s'additionne dans le temps et entre les lots ;
|
||||
- un **stock** est une photo à un instant (ce qui reste dû au 22/06). Il
|
||||
s'additionne entre les lots à une même date, jamais dans le temps :
|
||||
additionner deux photos du même solde compte deux fois la même dette.
|
||||
|
||||
Chaque compte rendu reporte la dette du précédent dans une ligne
|
||||
``solde_anterieur``. Cumuler ces lignes sur une période revient donc à recompter
|
||||
la même créance autant de fois qu'il y a de documents — et rend surtout
|
||||
impossible d'enregistrer un remboursement : une dette soldée resterait dans le
|
||||
total à vie.
|
||||
|
||||
Attention, **c'est la colonne qui porte la nature, pas la ligne**. Une ligne de
|
||||
report contient les deux : sa colonne ``total`` est un stock déjà compté le mois
|
||||
précédent, mais sa colonne ``regles`` est un encaissement bien réel de la
|
||||
période. Écarter la ligne entière ferait disparaître de l'argent reçu.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
|
||||
from ..database.models import Document, Revenu
|
||||
|
||||
#: Type des lignes qui reportent le solde du compte rendu précédent.
|
||||
TYPE_LIGNE_REPORT = "solde_anterieur"
|
||||
|
||||
|
||||
def est_flux():
|
||||
"""Condition : la ligne décrit un événement de la période, pas un report."""
|
||||
return Revenu.type_ligne != TYPE_LIGNE_REPORT
|
||||
|
||||
|
||||
def _borner(stmt, date_debut: date | None, date_fin: date | None):
|
||||
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
|
||||
|
||||
|
||||
def derniers_comptes_rendus(
|
||||
date_debut: date | None = None, date_fin: date | None = None
|
||||
):
|
||||
"""Sous-requête : date du dernier compte rendu de chaque immeuble.
|
||||
|
||||
Le dernier compte rendu est retenu **par immeuble** : avec plusieurs
|
||||
immeubles, le dernier document tous immeubles confondus n'en décrirait
|
||||
qu'un seul et les autres perdraient leur solde.
|
||||
"""
|
||||
stmt = select(
|
||||
Document.immeuble_id.label("immeuble_id"),
|
||||
func.max(Document.date).label("date"),
|
||||
).group_by(Document.immeuble_id)
|
||||
return _borner(stmt, date_debut, date_fin).subquery()
|
||||
|
||||
|
||||
def restant_du_par(
|
||||
cle, date_debut: date | None = None, date_fin: date | None = None
|
||||
):
|
||||
"""Sous-requête : restant dû (stock) regroupé par `cle`.
|
||||
|
||||
Seules les lignes du dernier compte rendu de chaque immeuble sont lues :
|
||||
c'est la seule photo à jour. Un lot absent de ce compte rendu est sorti de
|
||||
la gestion et ne compte plus — les présences observées sont contiguës, une
|
||||
absence n'est jamais un simple trou.
|
||||
|
||||
Args:
|
||||
cle: colonne de regroupement (``Revenu.lot_id``, ``Document.immeuble_id``…)
|
||||
date_debut: borne basse optionnelle sur la date du document
|
||||
date_fin: borne haute optionnelle
|
||||
|
||||
Returns:
|
||||
Sous-requête exposant `cle` et ``restant_du``.
|
||||
"""
|
||||
derniers = derniers_comptes_rendus(date_debut, date_fin)
|
||||
return (
|
||||
select(
|
||||
cle.label("cle"),
|
||||
func.sum(Revenu.impayes).label("restant_du"),
|
||||
)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.join(
|
||||
derniers,
|
||||
and_(
|
||||
derniers.c.immeuble_id == Document.immeuble_id,
|
||||
derniers.c.date == Document.date,
|
||||
),
|
||||
)
|
||||
.group_by(cle)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def flux_par(cle, date_debut: date | None = None, date_fin: date | None = None):
|
||||
"""Sous-requête : montants cumulables (flux) regroupés par `cle`.
|
||||
|
||||
Expose :
|
||||
|
||||
- ``facture`` : ce qui a été facturé sur la période, report exclu ;
|
||||
- ``encaisse`` : tout ce qui a été reçu, **y compris** les règlements de
|
||||
dettes anciennes portés par les lignes de report ;
|
||||
- ``facture_regle`` : la part de ``facture`` qui a été réglée. Sert au taux
|
||||
de recouvrement, qui doit comparer un périmètre homogène : rapporter
|
||||
``encaisse`` à ``facture`` ferait dépasser 100 % dès qu'une vieille dette
|
||||
est rattrapée.
|
||||
"""
|
||||
flux = est_flux()
|
||||
stmt = (
|
||||
select(
|
||||
cle.label("cle"),
|
||||
func.sum(Revenu.loyers).filter(flux).label("loyers"),
|
||||
func.sum(Revenu.taxes).filter(flux).label("taxes"),
|
||||
func.sum(Revenu.provisions).filter(flux).label("provisions"),
|
||||
func.sum(Revenu.total).filter(flux).label("facture"),
|
||||
func.sum(Revenu.regles).label("encaisse"),
|
||||
func.sum(Revenu.regles).filter(flux).label("facture_regle"),
|
||||
)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.group_by(cle)
|
||||
)
|
||||
return _borner(stmt, date_debut, date_fin).subquery()
|
||||
|
||||
|
||||
def taux_de_recouvrement(facture: float | None, facture_regle: float | None) -> float:
|
||||
"""Part du facturé qui a été réglée, en pourcentage.
|
||||
|
||||
Sans rien de facturé, il n'y a rien à recouvrer : le taux vaut 100 %.
|
||||
"""
|
||||
facture = facture or 0.0
|
||||
if facture <= 0:
|
||||
return 100.0
|
||||
return round((facture_regle or 0.0) / facture * 100, 1)
|
||||
@@ -48,6 +48,36 @@ def normalize_lot_numero(value: str | int | None) -> str | None:
|
||||
return significant.zfill(LOT_NUMERO_WIDTH)
|
||||
|
||||
|
||||
def extract_lot_numero_from_description(description: str) -> str | None:
|
||||
"""Extrait le numéro de lot depuis la description d'une opération.
|
||||
|
||||
Les codes lots suivent le format {Lettre}{Numéro} où la lettre identifie
|
||||
l'immeuble (M=Marietton, S=Servient, B=Bloch…) et le numéro le lot.
|
||||
|
||||
Exemples:
|
||||
- "M06 - Commande moteur pompe" -> "06"
|
||||
- "S05 - Mise en service" -> "05"
|
||||
- "B01 - Plaques" -> "01"
|
||||
|
||||
Args:
|
||||
description: Description de l'opération
|
||||
|
||||
Returns:
|
||||
Numéro sur 2 chiffres (ex: "06") ou None si non trouvé
|
||||
"""
|
||||
if not description:
|
||||
return None
|
||||
|
||||
# Pattern: lettre majuscule + (espace optionnelle) + 1-2 chiffres, terminé par
|
||||
# un espace, un tiret ou la fin. Gère "S10 - ...", "S 17 - ..." (espace dans le
|
||||
# code) et "S01 SOLDE ..." (code lot non suivi d'un tiret).
|
||||
match = re.search(r"\b[A-Z]\s*(\d{1,2})(?=[\s-]|$)", description)
|
||||
if match:
|
||||
return normalize_lot_numero(match.group(1))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def normalize_extraction_lots(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalise, sur place, tous les numéros de lot d'une extraction.
|
||||
|
||||
|
||||
@@ -5,6 +5,22 @@ import pytest
|
||||
from plesna_gerance.database import connection
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
"""Ajoute --regen-golden pour refiger les références des parseurs."""
|
||||
parser.addoption(
|
||||
"--regen-golden",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Réécrit les références de tests/golden au lieu de les comparer.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regen_golden(request) -> bool:
|
||||
"""Vrai quand la suite est lancée avec --regen-golden."""
|
||||
return request.config.getoption("--regen-golden")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(tmp_path, monkeypatch):
|
||||
"""Session SQLAlchemy sur une base SQLite temporaire et isolée.
|
||||
@@ -28,6 +44,27 @@ def db_session(tmp_path, monkeypatch):
|
||||
connection.reset_connection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client(db_session):
|
||||
"""Client HTTP sur l'application FastAPI, branché sur la base de test.
|
||||
|
||||
La dépendance `get_session` est surchargée pour partager la session du test :
|
||||
les données créées dans le test sont visibles par les endpoints, sans passer
|
||||
par la base réelle de l'utilisateur.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from plesna_gerance.api.app import app
|
||||
from plesna_gerance.database import get_session
|
||||
|
||||
app.dependency_overrides[get_session] = lambda: db_session
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data():
|
||||
"""Données extraites minimales mais complètes pour save_document."""
|
||||
|
||||
101
tests/test_dashboard_coherence.py
Normal file
101
tests/test_dashboard_coherence.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Le dashboard et la page Revenus doivent annoncer les memes chiffres.
|
||||
|
||||
Les deux ecrans calculaient leurs totaux chacun de leur cote, avec des regles
|
||||
differentes : l'accueil lisait le dernier compte rendu, la page Revenus cumulait
|
||||
tout. Ils affichaient donc deux montants d'impayes pour la meme notion. Ces
|
||||
tests verrouillent leur accord.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.database.service import DatabaseService
|
||||
|
||||
|
||||
def _mois_glissant(recul: int) -> str:
|
||||
jour = date.today().replace(day=15)
|
||||
for _ in range(recul):
|
||||
jour = (jour.replace(day=1) - timedelta(days=1)).replace(day=15)
|
||||
return jour.isoformat()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deux_comptes_rendus(db_session, sample_data):
|
||||
"""Un impaye de 300 ne le mois dernier, reporte et non regle ce mois-ci."""
|
||||
service = DatabaseService(db_session)
|
||||
|
||||
premier = copy.deepcopy(sample_data)
|
||||
premier["metadata"]["document"]["reference"] = "M1"
|
||||
premier["metadata"]["document"]["date"] = _mois_glissant(1)
|
||||
premier["situation_locataires"][0]["lignes"] = [
|
||||
{
|
||||
"type": "loyer",
|
||||
"periode": {"debut": None, "fin": None},
|
||||
"loyers": 800.0,
|
||||
"total": 800.0,
|
||||
"regles": 500.0,
|
||||
"impayes": 300.0,
|
||||
}
|
||||
]
|
||||
service.save_document(data=premier)
|
||||
|
||||
second = copy.deepcopy(sample_data)
|
||||
second["metadata"]["document"]["reference"] = "M2"
|
||||
second["metadata"]["document"]["date"] = _mois_glissant(0)
|
||||
second["situation_locataires"][0]["lignes"] = [
|
||||
{
|
||||
"type": "solde_anterieur",
|
||||
"periode": {"debut": None, "fin": None},
|
||||
"loyers": 300.0,
|
||||
"total": 300.0,
|
||||
"regles": 0.0,
|
||||
"impayes": 300.0,
|
||||
},
|
||||
{
|
||||
"type": "loyer",
|
||||
"periode": {"debut": None, "fin": None},
|
||||
"loyers": 800.0,
|
||||
"total": 800.0,
|
||||
"regles": 800.0,
|
||||
"impayes": 0.0,
|
||||
},
|
||||
]
|
||||
service.save_document(data=second)
|
||||
return db_session
|
||||
|
||||
|
||||
def test_les_deux_ecrans_annoncent_le_meme_impaye(api_client, deux_comptes_rendus):
|
||||
"""300 dus, vus depuis l'accueil comme depuis la page Revenus."""
|
||||
accueil = api_client.get("/api/dashboard/financial-summary").json()
|
||||
revenus = api_client.get("/api/revenus/summary").json()
|
||||
|
||||
assert accueil["impayes"] == 300.0
|
||||
assert revenus["kpis"]["total_impayes"] == 300.0
|
||||
|
||||
|
||||
def test_le_revenu_du_dernier_compte_rendu_exclut_le_report(
|
||||
api_client, deux_comptes_rendus
|
||||
):
|
||||
"""Le mois vaut son loyer de 800, pas 1100 report compris."""
|
||||
accueil = api_client.get("/api/dashboard/financial-summary").json()
|
||||
|
||||
assert accueil["revenus"] == 800.0
|
||||
|
||||
|
||||
def test_les_raccourcis_immeubles_ne_cumulent_pas_la_dette(
|
||||
api_client, deux_comptes_rendus
|
||||
):
|
||||
"""Le raccourci montre la dette en cours, pas sa somme mois apres mois."""
|
||||
(raccourci,) = api_client.get("/api/dashboard/immeubles-shortcuts").json()
|
||||
|
||||
assert raccourci["total_impayes"] == 300.0
|
||||
assert raccourci["total_revenus"] == 1600.0 # 800 + 800, report exclu
|
||||
|
||||
|
||||
def test_la_tendance_mensuelle_reste_comparable(api_client, deux_comptes_rendus):
|
||||
"""Chaque mois pese son loyer, sinon le second parait meilleur qu'il n'est."""
|
||||
tendances = api_client.get("/api/dashboard/monthly-trends").json()
|
||||
|
||||
assert [point["revenus"] for point in tendances] == [800.0, 800.0]
|
||||
63
tests/test_extractor.py
Normal file
63
tests/test_extractor.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Tests de l'orchestrateur d'extraction.
|
||||
|
||||
Depuis la suppression des parseurs texte de repli, le parseur geometrique est le
|
||||
seul chemin : ce qu'il ne lit pas est definitivement perdu. L'orchestrateur doit
|
||||
donc distinguer un PDF illisible d'un mois calme.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance import extractor
|
||||
from plesna_gerance.extractor import ExtractionError, extract_compte_rendu
|
||||
from plesna_gerance.parsers.pdf import PdfContent
|
||||
|
||||
_UN_LOT = [{"lot": {"numero": "01", "type": "Appartement T2"}, "lignes": []}]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parseurs(monkeypatch):
|
||||
"""Neutralise la lecture PDF et pilote ce que renvoie chaque parseur."""
|
||||
|
||||
def configurer(situation, operations):
|
||||
monkeypatch.setattr(
|
||||
extractor, "read_pdf", lambda _: PdfContent(text="", words=[])
|
||||
)
|
||||
monkeypatch.setattr(extractor, "extract_metadata", lambda *_: {})
|
||||
monkeypatch.setattr(
|
||||
extractor, "extract_situation_locataires_from_pdf", lambda _: situation
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
extractor, "extract_recapitulatif_operations_from_pdf", lambda _: operations
|
||||
)
|
||||
|
||||
return configurer
|
||||
|
||||
|
||||
def test_sans_aucun_lot_l_extraction_echoue(parseurs):
|
||||
"""Un PDF dont le tableau des locataires est illisible doit lever."""
|
||||
parseurs(situation=[], operations=[{"categorie": "DIVERS"}])
|
||||
|
||||
with pytest.raises(ExtractionError, match="Aucun lot"):
|
||||
extract_compte_rendu("document.pdf")
|
||||
|
||||
|
||||
def test_sans_operation_l_extraction_reussit(parseurs):
|
||||
"""Un mois sans depense est plausible : la liste vide est acceptee."""
|
||||
parseurs(situation=_UN_LOT, operations=[])
|
||||
|
||||
resultat = extract_compte_rendu("document.pdf")
|
||||
|
||||
assert resultat["situation_locataires"] == _UN_LOT
|
||||
assert resultat["recapitulatif_operations"] == []
|
||||
|
||||
|
||||
def test_extraction_complete(parseurs):
|
||||
"""Cas nominal : les deux sections sont remontees telles quelles."""
|
||||
operations = [{"categorie": "DEPENSES_LOCATIVES"}]
|
||||
parseurs(situation=_UN_LOT, operations=operations)
|
||||
|
||||
resultat = extract_compte_rendu("document.pdf")
|
||||
|
||||
assert resultat["situation_locataires"] == _UN_LOT
|
||||
assert resultat["recapitulatif_operations"] == operations
|
||||
assert "metadata" in resultat
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.parsers.operations import _extract_lot_code_from_description
|
||||
from plesna_gerance.utils.lots import normalize_extraction_lots, normalize_lot_numero
|
||||
from plesna_gerance.utils.lots import (
|
||||
extract_lot_numero_from_description,
|
||||
normalize_extraction_lots,
|
||||
normalize_lot_numero,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -44,8 +47,8 @@ def test_normalize_lot_numero(raw, expected):
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_extract_lot_code_from_description(description, expected):
|
||||
assert _extract_lot_code_from_description(description) == expected
|
||||
def test_extract_lot_numero_from_description(description, expected):
|
||||
assert extract_lot_numero_from_description(description) == expected
|
||||
|
||||
|
||||
def test_normalize_extraction_lots():
|
||||
|
||||
155
tests/test_parsers_golden.py
Normal file
155
tests/test_parsers_golden.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Tests de non-regression des parseurs sur les PDF reels.
|
||||
|
||||
Les parseurs sont le coeur du produit et la partie la plus exposee aux
|
||||
regressions silencieuses : un decalage de colonne ne leve aucune exception, il
|
||||
produit juste des montants faux. Ces tests figent, pour chaque PDF du corpus
|
||||
local, une **empreinte** de l'extraction (structure des lots, totaux par lot,
|
||||
montants par categorie d'operation) et signalent tout ecart.
|
||||
|
||||
Les comptes rendus contiennent des donnees personnelles : ni les PDF (`data/`)
|
||||
ni les references generees (`tests/golden/`) ne sont versionnes. La suite se
|
||||
saute donc d'elle-meme la ou le corpus est absent, CI comprise. Les libelles
|
||||
sensibles (locataire, fournisseur, description) sont reduits a une empreinte
|
||||
courte : un changement reste detecte, sans recopier la donnee.
|
||||
|
||||
Regenerer les references apres un changement volontaire de parseur :
|
||||
|
||||
uv run pytest tests/test_parsers_golden.py --regen-golden
|
||||
|
||||
Puis relire le `git diff`... qui n'existe pas ici : comparer a la main la sortie
|
||||
avant/apres, ou versionner temporairement le dossier pour l'inspecter.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.parsers.locataires_table import (
|
||||
extract_situation_locataires_from_pdf,
|
||||
)
|
||||
from plesna_gerance.parsers.operations_table import (
|
||||
extract_recapitulatif_operations_from_pdf,
|
||||
)
|
||||
|
||||
_RACINE = Path(__file__).resolve().parent.parent
|
||||
_CORPUS = _RACINE / "data" / "documents"
|
||||
_GOLDEN = Path(__file__).resolve().parent / "golden"
|
||||
|
||||
_MONTANTS_OPERATION = ("debit", "credit", "tva", "locatif", "deductible")
|
||||
|
||||
|
||||
def _pdfs() -> list[Path]:
|
||||
"""PDF du corpus local, tries pour un ordre de test stable."""
|
||||
if not _CORPUS.is_dir():
|
||||
return []
|
||||
return sorted(_CORPUS.rglob("*.pdf"))
|
||||
|
||||
|
||||
def _empreinte_libelle(valeur: str | None) -> str | None:
|
||||
"""Empreinte courte d'un libelle sensible (nom, fournisseur, description).
|
||||
|
||||
Detecte toute modification du libelle sans en conserver le contenu.
|
||||
"""
|
||||
if not valeur:
|
||||
return None
|
||||
normalise = " ".join(valeur.split())
|
||||
return hashlib.sha1(normalise.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
def _empreinte_lot(situation: dict) -> dict:
|
||||
types_lignes: dict[str, int] = {}
|
||||
for ligne in situation.get("lignes", []):
|
||||
type_ligne = ligne.get("type", "?")
|
||||
types_lignes[type_ligne] = types_lignes.get(type_ligne, 0) + 1
|
||||
|
||||
return {
|
||||
"numero": situation.get("lot", {}).get("numero"),
|
||||
"type": situation.get("lot", {}).get("type"),
|
||||
"locataire": _empreinte_libelle(situation.get("locataire", {}).get("nom")),
|
||||
"nb_lignes": len(situation.get("lignes", [])),
|
||||
"types_lignes": dict(sorted(types_lignes.items())),
|
||||
"totaux": situation.get("totaux", {}),
|
||||
}
|
||||
|
||||
|
||||
def _empreinte_operation(operation: dict) -> dict:
|
||||
return {
|
||||
"categorie": operation.get("categorie"),
|
||||
"sous_categorie": _empreinte_libelle(operation.get("sous_categorie")),
|
||||
"fournisseur": _empreinte_libelle(operation.get("fournisseur")),
|
||||
"description": _empreinte_libelle(operation.get("description")),
|
||||
"lot_numero": operation.get("lot_numero"),
|
||||
"montants": operation.get("montants", {}),
|
||||
}
|
||||
|
||||
|
||||
def _empreinte(situations: list[dict], operations: list[dict]) -> dict:
|
||||
"""Empreinte complete d'une extraction, comparable d'une execution a l'autre."""
|
||||
par_categorie: dict[str, dict] = {}
|
||||
for operation in operations:
|
||||
categorie = operation.get("categorie") or "SANS_CATEGORIE"
|
||||
agrege = par_categorie.setdefault(
|
||||
categorie, {"nb": 0, **{cle: 0.0 for cle in _MONTANTS_OPERATION}}
|
||||
)
|
||||
agrege["nb"] += 1
|
||||
for cle in _MONTANTS_OPERATION:
|
||||
agrege[cle] = round(
|
||||
agrege[cle] + (operation.get("montants", {}).get(cle) or 0.0), 2
|
||||
)
|
||||
|
||||
return {
|
||||
"situation_locataires": {
|
||||
"nb_lots": len(situations),
|
||||
"lots": [_empreinte_lot(situation) for situation in situations],
|
||||
},
|
||||
"recapitulatif_operations": {
|
||||
"nb_operations": len(operations),
|
||||
"par_categorie": dict(sorted(par_categorie.items())),
|
||||
"operations": [_empreinte_operation(op) for op in operations],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _pdfs(), reason="corpus PDF local absent (data/documents)")
|
||||
@pytest.mark.parametrize("pdf", _pdfs(), ids=lambda p: p.stem)
|
||||
def test_extraction_conforme_a_la_reference(pdf: Path, regen_golden: bool):
|
||||
"""L'extraction d'un PDF reel reste identique a sa reference figee."""
|
||||
obtenue = _empreinte(
|
||||
extract_situation_locataires_from_pdf(str(pdf)),
|
||||
extract_recapitulatif_operations_from_pdf(str(pdf)),
|
||||
)
|
||||
|
||||
reference = _GOLDEN / f"{pdf.stem}.json"
|
||||
|
||||
if regen_golden:
|
||||
_GOLDEN.mkdir(parents=True, exist_ok=True)
|
||||
reference.write_text(
|
||||
json.dumps(obtenue, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
pytest.skip(f"reference regeneree : {reference.name}")
|
||||
|
||||
if not reference.exists():
|
||||
pytest.fail(
|
||||
f"Reference absente pour {pdf.name}. "
|
||||
"Generer avec : uv run pytest tests/test_parsers_golden.py --regen-golden"
|
||||
)
|
||||
|
||||
assert obtenue == json.loads(reference.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _pdfs(), reason="corpus PDF local absent (data/documents)")
|
||||
def test_le_parseur_geometrique_couvre_tout_le_corpus():
|
||||
"""Aucun PDF du corpus ne ressort vide du parseur geometrique.
|
||||
|
||||
Verrouille la decision de supprimer les parseurs texte de repli : le jour ou
|
||||
un PDF echappe au parseur par cellules, ce test le signale plutot que de
|
||||
laisser passer un document vide.
|
||||
"""
|
||||
vides = [
|
||||
pdf.name
|
||||
for pdf in _pdfs()
|
||||
if not extract_situation_locataires_from_pdf(str(pdf))
|
||||
]
|
||||
assert vides == []
|
||||
107
tests/test_revenus_agregats.py
Normal file
107
tests/test_revenus_agregats.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Tests des agregats de revenus par immeuble.
|
||||
|
||||
Ces endpoints joignent revenus et locataires a partir du lot. Comme un lot
|
||||
accumule les locataires successifs, une jointure naive multiplie chaque ligne de
|
||||
revenu par le nombre d'occupants passes et gonfle silencieusement les totaux :
|
||||
aucune erreur, juste des montants faux qui derivent avec l'anciennete du parc.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.database.models import Locataire, Revenu
|
||||
from plesna_gerance.database.service import DatabaseService
|
||||
|
||||
|
||||
def _mois_glissant(recul: int) -> str:
|
||||
"""Date du 15 du mois, `recul` mois en arriere.
|
||||
|
||||
Les resumes ne portent que sur les derniers mois : des dates fixes
|
||||
sortiraient de la fenetre avec le temps et videraient les tests de leur
|
||||
substance sans jamais les faire echouer.
|
||||
"""
|
||||
jour = date.today().replace(day=15)
|
||||
for _ in range(recul):
|
||||
jour = (jour.replace(day=1) - timedelta(days=1)).replace(day=15)
|
||||
return jour.isoformat()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lot_a_deux_locataires(db_session, sample_data):
|
||||
"""Un lot occupe successivement par deux locataires, un revenu chacun.
|
||||
|
||||
Reproduit la situation courante d'une relocation : meme immeuble, meme lot,
|
||||
deux noms differents sur deux comptes rendus.
|
||||
"""
|
||||
service = DatabaseService(db_session)
|
||||
premier = copy.deepcopy(sample_data)
|
||||
premier["metadata"]["document"]["date"] = _mois_glissant(2)
|
||||
service.save_document(data=premier)
|
||||
|
||||
suivant = copy.deepcopy(sample_data)
|
||||
suivant["metadata"]["document"]["reference"] = "REF002"
|
||||
suivant["metadata"]["document"]["date"] = _mois_glissant(1)
|
||||
suivant["situation_locataires"][0]["locataire"]["nom"] = "MARTIN"
|
||||
suivant["situation_locataires"][0]["lignes"][0]["loyers"] = 600.0
|
||||
suivant["situation_locataires"][0]["lignes"][0]["total"] = 600.0
|
||||
suivant["situation_locataires"][0]["lignes"][0]["regles"] = 400.0
|
||||
suivant["situation_locataires"][0]["lignes"][0]["impayes"] = 200.0
|
||||
service.save_document(data=suivant)
|
||||
|
||||
# Le decor doit bien etre celui qu'on veut tester, sinon le test ne prouve rien.
|
||||
assert db_session.query(Locataire).count() == 2
|
||||
assert db_session.query(Revenu).count() == 2
|
||||
return db_session
|
||||
|
||||
|
||||
#: Le decor : 500 puis 600 factures, 500 puis 400 regles, 200 encore dus.
|
||||
FACTURE = 1100.0
|
||||
ENCAISSE = 900.0
|
||||
RESTANT_DU = 200.0
|
||||
|
||||
|
||||
def test_immeubles_ne_compte_pas_les_revenus_en_double(
|
||||
api_client, lot_a_deux_locataires
|
||||
):
|
||||
"""/api/revenus/immeubles somme chaque revenu une seule fois."""
|
||||
response = api_client.get("/api/revenus/immeubles")
|
||||
assert response.status_code == 200
|
||||
(immeuble,) = response.json()
|
||||
|
||||
assert immeuble["total_revenus"] == FACTURE
|
||||
assert immeuble["total_regles"] == ENCAISSE
|
||||
assert immeuble["total_impayes"] == RESTANT_DU
|
||||
|
||||
|
||||
def test_summary_ne_compte_pas_les_revenus_en_double(
|
||||
api_client, lot_a_deux_locataires
|
||||
):
|
||||
"""Le bloc by_immeuble de /api/revenus/summary somme comme les KPIs."""
|
||||
response = api_client.get("/api/revenus/summary")
|
||||
assert response.status_code == 200
|
||||
corps = response.json()
|
||||
(immeuble,) = corps["by_immeuble"]
|
||||
|
||||
assert immeuble["total_revenus"] == FACTURE
|
||||
assert immeuble["total_regles"] == ENCAISSE
|
||||
assert immeuble["total_impayes"] == RESTANT_DU
|
||||
# Les deux blocs doivent raconter la meme chose.
|
||||
assert corps["kpis"]["total_revenus"] == immeuble["total_revenus"]
|
||||
assert corps["kpis"]["total_impayes"] == immeuble["total_impayes"]
|
||||
|
||||
|
||||
def test_immeubles_compte_lots_et_locataires(api_client, lot_a_deux_locataires):
|
||||
"""Les effectifs restent justes : un lot, ses deux occupants successifs."""
|
||||
(immeuble,) = api_client.get("/api/revenus/immeubles").json()
|
||||
|
||||
assert immeuble["nb_lots"] == 1
|
||||
assert immeuble["nb_locataires"] == 2
|
||||
|
||||
|
||||
def test_taux_recouvrement_reste_coherent(api_client, lot_a_deux_locataires):
|
||||
"""Le taux se deduit des totaux : il doit suivre la meme correction."""
|
||||
(immeuble,) = api_client.get("/api/revenus/immeubles").json()
|
||||
|
||||
assert immeuble["taux_recouvrement"] == round(ENCAISSE / FACTURE * 100, 1)
|
||||
148
tests/test_revenus_flux_stock.py
Normal file
148
tests/test_revenus_flux_stock.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Tests de la distinction flux / stock dans les agregats de revenus.
|
||||
|
||||
Chaque compte rendu reporte la dette du precedent dans une ligne
|
||||
`solde_anterieur`. Cumuler ces lignes recompte la meme creance a chaque
|
||||
document et, surtout, empeche un remboursement de s'inscrire : une dette soldee
|
||||
resterait affichee a vie.
|
||||
|
||||
Le decor rejoue le cycle observe en production : un locataire laisse un impaye,
|
||||
le compte rendu suivant le reporte, il le solde, puis le reporte disparait.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.database.service import DatabaseService
|
||||
|
||||
|
||||
def _mois_glissant(recul: int) -> str:
|
||||
jour = date.today().replace(day=15)
|
||||
for _ in range(recul):
|
||||
jour = (jour.replace(day=1) - timedelta(days=1)).replace(day=15)
|
||||
return jour.isoformat()
|
||||
|
||||
|
||||
def _ligne(type_ligne: str, **montants) -> dict:
|
||||
base = {
|
||||
"type": type_ligne,
|
||||
"periode": {"debut": None, "fin": None},
|
||||
"loyers": 0.0,
|
||||
"taxes": 0.0,
|
||||
"provisions": 0.0,
|
||||
"total": 0.0,
|
||||
"regles": 0.0,
|
||||
"impayes": 0.0,
|
||||
}
|
||||
base.update(montants)
|
||||
return base
|
||||
|
||||
|
||||
def _compte_rendu(sample_data: dict, reference: str, recul: int, lignes: list) -> dict:
|
||||
data = copy.deepcopy(sample_data)
|
||||
data["metadata"]["document"]["reference"] = reference
|
||||
data["metadata"]["document"]["date"] = _mois_glissant(recul)
|
||||
data["situation_locataires"][0]["lignes"] = lignes
|
||||
data["recapitulatif_operations"] = []
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dette_reportee_puis_soldee(db_session, sample_data):
|
||||
"""Trois mois : un impaye nait, il est reporte, il est solde.
|
||||
|
||||
Mois 1 : loyer de 800 dont 300 impayes.
|
||||
Mois 2 : les 300 sont reportes et regles ; loyer de 800 regle en entier.
|
||||
Mois 3 : plus aucun report ; loyer de 800 regle en entier.
|
||||
"""
|
||||
service = DatabaseService(db_session)
|
||||
|
||||
service.save_document(
|
||||
data=_compte_rendu(
|
||||
sample_data,
|
||||
"M1",
|
||||
2,
|
||||
[_ligne("loyer", loyers=800.0, total=800.0, regles=500.0, impayes=300.0)],
|
||||
)
|
||||
)
|
||||
service.save_document(
|
||||
data=_compte_rendu(
|
||||
sample_data,
|
||||
"M2",
|
||||
1,
|
||||
[
|
||||
_ligne("solde_anterieur", loyers=300.0, total=300.0, regles=300.0),
|
||||
_ligne("loyer", loyers=800.0, total=800.0, regles=800.0),
|
||||
],
|
||||
)
|
||||
)
|
||||
service.save_document(
|
||||
data=_compte_rendu(
|
||||
sample_data,
|
||||
"M3",
|
||||
0,
|
||||
[_ligne("loyer", loyers=800.0, total=800.0, regles=800.0)],
|
||||
)
|
||||
)
|
||||
return db_session
|
||||
|
||||
|
||||
def test_le_restant_du_est_celui_du_dernier_compte_rendu(
|
||||
api_client, dette_reportee_puis_soldee
|
||||
):
|
||||
"""La dette soldee disparait : le cumul afficherait encore 300."""
|
||||
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
|
||||
|
||||
assert kpis["total_impayes"] == 0.0
|
||||
|
||||
|
||||
def test_le_facture_ignore_le_report(api_client, dette_reportee_puis_soldee):
|
||||
"""3 loyers de 800 : le report de 300 n'est pas un revenu de plus."""
|
||||
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
|
||||
|
||||
assert kpis["total_revenus"] == 2400.0
|
||||
|
||||
|
||||
def test_l_encaisse_retient_le_reglement_d_une_vieille_dette(
|
||||
api_client, dette_reportee_puis_soldee
|
||||
):
|
||||
"""Les 300 regles sur la ligne de report sont de l'argent bien recu.
|
||||
|
||||
C'est ce qui interdit d'ecarter la ligne de report en bloc : sa colonne
|
||||
`total` est un stock deja compte, mais sa colonne `regles` est un flux.
|
||||
"""
|
||||
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
|
||||
|
||||
assert kpis["total_regles"] == 500.0 + 300.0 + 800.0 + 800.0
|
||||
|
||||
|
||||
def test_le_taux_compare_un_perimetre_homogene(
|
||||
api_client, dette_reportee_puis_soldee
|
||||
):
|
||||
"""Regle sur facture, report exclu des deux cotes : 2100 / 2400.
|
||||
|
||||
Rapporter l'encaisse (2400, rattrapage compris) au facture ferait afficher
|
||||
un taux de 100 % alors qu'un impaye est ne sur la periode.
|
||||
"""
|
||||
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
|
||||
|
||||
assert kpis["taux_recouvrement"] == round(2100.0 / 2400.0 * 100, 1)
|
||||
|
||||
|
||||
def test_le_classement_des_impayes_oublie_qui_a_paye(
|
||||
api_client, dette_reportee_puis_soldee
|
||||
):
|
||||
"""Un locataire a jour ne doit plus figurer parmi les debiteurs."""
|
||||
top = api_client.get("/api/revenus/summary").json()["top_impayes"]
|
||||
|
||||
assert top == []
|
||||
|
||||
|
||||
def test_les_mois_restent_comparables(api_client, dette_reportee_puis_soldee):
|
||||
"""Chaque mois vaut son loyer : le report ne gonfle pas le mois 2."""
|
||||
by_month = api_client.get("/api/revenus/summary").json()["by_month"]
|
||||
|
||||
assert [point["total"] for point in by_month] == [800.0, 800.0, 800.0]
|
||||
# L'impaye reste le solde constate ce mois-la, pas un cumul.
|
||||
assert [point["impayes"] for point in by_month] == [300.0, 0.0, 0.0]
|
||||
41
tests/test_routage_spa.py
Normal file
41
tests/test_routage_spa.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Tests du partage des URL entre l'API et le SPA.
|
||||
|
||||
L'application sert l'API et l'interface sur le meme port : une route attrape
|
||||
tout ce qui ne correspond a aucun endpoint pour le renvoyer au routeur Vue. Elle
|
||||
ne doit pas avaler les URL d'API, sous peine de transformer une adresse erronee
|
||||
en page HTML repondue avec un 200.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.api.app import FRONTEND_DIST
|
||||
|
||||
|
||||
def test_une_url_d_api_inconnue_donne_404(api_client):
|
||||
response = api_client.get("/api/nexiste-pas")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
|
||||
|
||||
def test_une_url_d_api_inconnue_ne_renvoie_pas_le_spa(api_client):
|
||||
"""Meme forme que les vraies routes : le prefixe seul ne suffit pas."""
|
||||
response = api_client.get("/api/revenus/inconnu")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_les_routes_d_api_existantes_repondent(api_client):
|
||||
assert api_client.get("/api/health").status_code == 200
|
||||
assert api_client.get("/api/tags").status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not FRONTEND_DIST.exists(), reason="frontend non construit (frontend/dist)"
|
||||
)
|
||||
def test_une_route_du_spa_renvoie_l_interface(api_client):
|
||||
"""Les URL de l'interface restent servies par index.html."""
|
||||
response = api_client.get("/documents")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
58
tests/test_tags_api.py
Normal file
58
tests/test_tags_api.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Tests des endpoints de gestion des tags.
|
||||
|
||||
Lecture, creation et renommage vivaient a deux adresses differentes
|
||||
(`/api/tags` et `/api/config/tags`) : ces tests verrouillent l'adresse unique
|
||||
retenue, celle que consomment desormais toutes les pages.
|
||||
"""
|
||||
|
||||
|
||||
def test_liste_les_tags_predefinis(api_client):
|
||||
"""La base initialisee est deja pourvue de ses tags de depart."""
|
||||
response = api_client.get("/api/tags")
|
||||
|
||||
assert response.status_code == 200
|
||||
tags = response.json()
|
||||
assert tags, "la base devrait etre amorcee avec des tags predefinis"
|
||||
assert {"id", "nom"} == set(tags[0])
|
||||
|
||||
|
||||
def test_cree_un_tag(api_client):
|
||||
response = api_client.post("/api/tags", json={"nom": "Ravalement"})
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["nom"] == "Ravalement"
|
||||
assert "Ravalement" in [tag["nom"] for tag in api_client.get("/api/tags").json()]
|
||||
|
||||
|
||||
def test_refuse_un_tag_en_double(api_client):
|
||||
api_client.post("/api/tags", json={"nom": "Ravalement"})
|
||||
|
||||
response = api_client.post("/api/tags", json={"nom": "Ravalement"})
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_refuse_un_nom_vide(api_client):
|
||||
assert api_client.post("/api/tags", json={"nom": " "}).status_code == 400
|
||||
|
||||
|
||||
def test_renomme_un_tag(api_client):
|
||||
tag_id = api_client.post("/api/tags", json={"nom": "Ravalement"}).json()["id"]
|
||||
|
||||
response = api_client.put(f"/api/tags/{tag_id}", json={"nom": "Facade"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": tag_id, "nom": "Facade"}
|
||||
|
||||
|
||||
def test_renommer_un_tag_inconnu_donne_404(api_client):
|
||||
assert api_client.put("/api/tags/99999", json={"nom": "Facade"}).status_code == 404
|
||||
|
||||
|
||||
def test_refuse_un_renommage_vers_un_nom_pris(api_client):
|
||||
api_client.post("/api/tags", json={"nom": "Ravalement"})
|
||||
autre_id = api_client.post("/api/tags", json={"nom": "Toiture"}).json()["id"]
|
||||
|
||||
response = api_client.put(f"/api/tags/{autre_id}", json={"nom": "Ravalement"})
|
||||
|
||||
assert response.status_code == 409
|
||||
Reference in New Issue
Block a user