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() {
|
async function loadTags() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${API}/api/config/tags`)
|
const resp = await fetch(`${API}/api/tags`)
|
||||||
tags.value = await resp.json()
|
tags.value = await resp.json()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load tags', e)
|
console.error('Failed to load tags', e)
|
||||||
@@ -248,7 +248,7 @@ async function confirmRenameTag(tagId) {
|
|||||||
if (!nom) return
|
if (!nom) return
|
||||||
tagMessage.value = ''
|
tagMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${API}/api/config/tags/${tagId}`, {
|
const resp = await fetch(`${API}/api/tags/${tagId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ nom }),
|
body: JSON.stringify({ nom }),
|
||||||
@@ -274,7 +274,7 @@ async function createTag() {
|
|||||||
if (!nom) return
|
if (!nom) return
|
||||||
tagMessage.value = ''
|
tagMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${API}/api/config/tags`, {
|
const resp = await fetch(`${API}/api/tags`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ nom }),
|
body: JSON.stringify({ nom }),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
@@ -87,6 +87,13 @@ if FRONTEND_DIST.exists():
|
|||||||
@app.get("/{full_path:path}", include_in_schema=False)
|
@app.get("/{full_path:path}", include_in_schema=False)
|
||||||
async def serve_spa(full_path: str):
|
async def serve_spa(full_path: str):
|
||||||
"""Serve the SPA for all non-API routes."""
|
"""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
|
# If requesting a file that exists (and stays within dist), serve it
|
||||||
file_path = (FRONTEND_DIST / full_path).resolve()
|
file_path = (FRONTEND_DIST / full_path).resolve()
|
||||||
if file_path.is_file() and file_path.is_relative_to(_dist_root):
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ...database import DatabaseService, get_session
|
from ...database import get_session
|
||||||
from ...database.models import Tag
|
|
||||||
from ...services.settings_service import (
|
from ...services.settings_service import (
|
||||||
SETTINGS_REGISTRY,
|
SETTINGS_REGISTRY,
|
||||||
delete_setting,
|
delete_setting,
|
||||||
@@ -25,14 +24,6 @@ class SettingUpdate(BaseModel):
|
|||||||
value: str
|
value: str
|
||||||
|
|
||||||
|
|
||||||
class TagCreate(BaseModel):
|
|
||||||
nom: str
|
|
||||||
|
|
||||||
|
|
||||||
class TagUpdate(BaseModel):
|
|
||||||
nom: str
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Settings endpoints
|
# Settings endpoints
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -71,64 +62,3 @@ async def reset_setting(
|
|||||||
# Return the resolved value after deletion
|
# Return the resolved value after deletion
|
||||||
all_settings = get_all_settings(session)
|
all_settings = get_all_settings(session)
|
||||||
return all_settings[key]
|
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,
|
Lot,
|
||||||
Revenu,
|
Revenu,
|
||||||
)
|
)
|
||||||
|
from ...services.revenus_query import est_flux, flux_par, restant_du_par
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
@@ -85,53 +86,11 @@ class ImmeubleShortcutResponse(BaseModel):
|
|||||||
total_impayes: float
|
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
|
# 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)
|
@router.get("/financial-summary", response_model=FinancialSummaryResponse)
|
||||||
async def get_financial_summary(
|
async def get_financial_summary(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -156,10 +115,13 @@ async def get_financial_summary(
|
|||||||
last_document_date = str(last_doc.date)
|
last_document_date = str(last_doc.date)
|
||||||
last_document_reference = last_doc.reference
|
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 = (
|
revenus = (
|
||||||
session.execute(
|
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()
|
).scalar()
|
||||||
or 0.0
|
or 0.0
|
||||||
)
|
)
|
||||||
@@ -195,11 +157,13 @@ async def get_financial_summary(
|
|||||||
impayes_by_month: dict[str, float] = defaultdict(float)
|
impayes_by_month: dict[str, float] = defaultdict(float)
|
||||||
depenses_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 = (
|
revenus_stmt = (
|
||||||
select(
|
select(
|
||||||
Document.date,
|
Document.date,
|
||||||
func.sum(Revenu.total).label("total"),
|
func.sum(Revenu.total).filter(est_flux()).label("total"),
|
||||||
func.sum(Revenu.impayes).label("impayes"),
|
func.sum(Revenu.impayes).label("impayes"),
|
||||||
)
|
)
|
||||||
.join(Revenu, Revenu.document_id == Document.id)
|
.join(Revenu, Revenu.document_id == Document.id)
|
||||||
@@ -319,9 +283,10 @@ async def get_monthly_trends(
|
|||||||
today = date.today()
|
today = date.today()
|
||||||
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
|
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 = (
|
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)
|
.join(Revenu, Revenu.document_id == Document.id)
|
||||||
.where(Document.date >= start_date)
|
.where(Document.date >= start_date)
|
||||||
.group_by(Document.date)
|
.group_by(Document.date)
|
||||||
@@ -375,51 +340,55 @@ async def get_immeubles_shortcuts(
|
|||||||
|
|
||||||
- **limit**: Nombre maximum d'immeubles (defaut: 5)
|
- **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 = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
Immeuble,
|
Immeuble,
|
||||||
func.count(func.distinct(Lot.id)).label("nb_lots"),
|
effectifs.c.nb_lots,
|
||||||
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
|
effectifs.c.nb_locataires,
|
||||||
func.count(func.distinct(Document.id)).label("nb_documents"),
|
flux.c.facture,
|
||||||
|
stock.c.restant_du,
|
||||||
)
|
)
|
||||||
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
|
.outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id)
|
||||||
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
|
.outerjoin(activite, activite.c.immeuble_id == Immeuble.id)
|
||||||
.outerjoin(Document, Document.immeuble_id == Immeuble.id)
|
.outerjoin(flux, flux.c.cle == Immeuble.id)
|
||||||
.group_by(Immeuble.id)
|
.outerjoin(stock, stock.c.cle == Immeuble.id)
|
||||||
.order_by(desc("nb_documents"))
|
.order_by(desc(activite.c.nb_documents))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = session.execute(stmt)
|
return [
|
||||||
immeubles = result.all()
|
ImmeubleShortcutResponse(
|
||||||
|
id=row.Immeuble.id,
|
||||||
# Pour chaque immeuble, recuperer les totaux revenus/impayes
|
code=row.Immeuble.code,
|
||||||
shortcuts = []
|
adresse=row.Immeuble.adresse,
|
||||||
for row in immeubles:
|
ville=row.Immeuble.ville,
|
||||||
immeuble = row.Immeuble
|
nb_lots=row.nb_lots or 0,
|
||||||
|
nb_locataires=row.nb_locataires or 0,
|
||||||
# Revenus de cet immeuble
|
total_revenus=row.facture or 0.0,
|
||||||
revenus_stmt = (
|
total_impayes=row.restant_du or 0.0,
|
||||||
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()
|
for row in session.execute(stmt)
|
||||||
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(
|
|
||||||
ImmeubleShortcutResponse(
|
|
||||||
id=immeuble.id,
|
|
||||||
code=immeuble.code,
|
|
||||||
adresse=immeuble.adresse,
|
|
||||||
ville=immeuble.ville,
|
|
||||||
nb_lots=row.nb_lots or 0,
|
|
||||||
nb_locataires=row.nb_locataires or 0,
|
|
||||||
total_revenus=total_revenus,
|
|
||||||
total_impayes=total_impayes,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return shortcuts
|
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ...database import DatabaseService, get_session, storage
|
from ...database import DatabaseService, get_session, storage
|
||||||
from ...database.models import Depense, Document, Immeuble, Locataire, Lot, Revenu
|
|
||||||
from ...database.service import DuplicateDocumentError
|
from ...database.service import DuplicateDocumentError
|
||||||
from ...extractor import extract_compte_rendu
|
from ...extractor import extract_compte_rendu
|
||||||
from ...utils.canonical import canonical_copy
|
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])
|
@router.get("/documents", response_model=list[DocumentSummary])
|
||||||
async def list_documents(
|
async def list_documents(
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ from ...database.models import (
|
|||||||
Lot,
|
Lot,
|
||||||
Revenu,
|
Revenu,
|
||||||
)
|
)
|
||||||
|
from ...services.revenus_query import (
|
||||||
|
est_flux,
|
||||||
|
flux_par,
|
||||||
|
restant_du_par,
|
||||||
|
taux_de_recouvrement,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/revenus", tags=["revenus"])
|
router = APIRouter(prefix="/api/revenus", tags=["revenus"])
|
||||||
|
|
||||||
@@ -127,6 +133,70 @@ class RevenusSummaryResponse(BaseModel):
|
|||||||
top_impayes: list[RevenuByLocataire]
|
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
|
# Endpoints
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -143,35 +213,32 @@ async def get_revenus_summary(
|
|||||||
Inclut les KPIs, l'evolution mensuelle, la repartition par immeuble
|
Inclut les KPIs, l'evolution mensuelle, la repartition par immeuble
|
||||||
et les locataires avec le plus d'impayes.
|
et les locataires avec le plus d'impayes.
|
||||||
"""
|
"""
|
||||||
# Base filters
|
|
||||||
filters = []
|
|
||||||
if immeuble_id:
|
|
||||||
filters.append(Lot.immeuble_id == immeuble_id)
|
|
||||||
|
|
||||||
# Calculate date range
|
# Calculate date range
|
||||||
today = date.today()
|
today = date.today()
|
||||||
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
|
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
|
||||||
|
|
||||||
# ========== KPIs ==========
|
# ========== KPIs ==========
|
||||||
kpi_stmt = select(
|
# Les montants factures se cumulent sur la periode ; le restant du est lu
|
||||||
func.sum(Revenu.total).label("total_revenus"),
|
# dans le dernier compte rendu, sans quoi une meme dette serait recomptee a
|
||||||
func.sum(Revenu.loyers).label("total_loyers"),
|
# chaque document et un remboursement ne s'y verrait jamais.
|
||||||
func.sum(Revenu.taxes).label("total_taxes"),
|
flux = flux_par(Document.immeuble_id, start_date)
|
||||||
func.sum(Revenu.provisions).label("total_provisions"),
|
stock = restant_du_par(Document.immeuble_id, start_date)
|
||||||
func.sum(Revenu.regles).label("total_regles"),
|
|
||||||
func.sum(Revenu.impayes).label("total_impayes"),
|
|
||||||
).join(Lot, Revenu.lot_id == Lot.id)
|
|
||||||
|
|
||||||
if filters:
|
flux_stmt = select(
|
||||||
kpi_stmt = kpi_stmt.where(and_(*filters))
|
func.sum(flux.c.loyers).label("loyers"),
|
||||||
|
func.sum(flux.c.taxes).label("taxes"),
|
||||||
kpi_result = session.execute(kpi_stmt).first()
|
func.sum(flux.c.provisions).label("provisions"),
|
||||||
|
func.sum(flux.c.facture).label("facture"),
|
||||||
total_revenus = kpi_result.total_revenus or 0.0
|
func.sum(flux.c.encaisse).label("encaisse"),
|
||||||
total_regles = kpi_result.total_regles or 0.0
|
func.sum(flux.c.facture_regle).label("facture_regle"),
|
||||||
taux_recouvrement = (
|
|
||||||
(total_regles / total_revenus * 100) if total_revenus > 0 else 100.0
|
|
||||||
)
|
)
|
||||||
|
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
|
# Count active locataires and occupied lots
|
||||||
locataires_stmt = (
|
locataires_stmt = (
|
||||||
@@ -194,96 +261,73 @@ async def get_revenus_summary(
|
|||||||
nb_lots = session.execute(lots_stmt).scalar() or 0
|
nb_lots = session.execute(lots_stmt).scalar() or 0
|
||||||
|
|
||||||
kpis = RevenuKpiResponse(
|
kpis = RevenuKpiResponse(
|
||||||
total_revenus=total_revenus,
|
total_revenus=totaux.facture or 0.0,
|
||||||
total_loyers=kpi_result.total_loyers or 0.0,
|
total_loyers=totaux.loyers or 0.0,
|
||||||
total_taxes=kpi_result.total_taxes or 0.0,
|
total_taxes=totaux.taxes or 0.0,
|
||||||
total_provisions=kpi_result.total_provisions or 0.0,
|
total_provisions=totaux.provisions or 0.0,
|
||||||
total_regles=total_regles,
|
total_regles=totaux.encaisse or 0.0,
|
||||||
total_impayes=kpi_result.total_impayes or 0.0,
|
total_impayes=restant_du,
|
||||||
taux_recouvrement=round(taux_recouvrement, 1),
|
taux_recouvrement=taux_de_recouvrement(totaux.facture, totaux.facture_regle),
|
||||||
nb_locataires_actifs=nb_locataires,
|
nb_locataires_actifs=nb_locataires,
|
||||||
nb_lots_occupes=nb_lots,
|
nb_lots_occupes=nb_lots,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========== Monthly evolution ==========
|
# ========== 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 = (
|
monthly_stmt = (
|
||||||
select(
|
select(
|
||||||
func.strftime("%Y-%m", Document.date).label("month"),
|
mois.label("month"),
|
||||||
func.sum(Revenu.loyers).label("loyers"),
|
func.sum(Revenu.loyers).filter(flux_mois).label("loyers"),
|
||||||
func.sum(Revenu.taxes).label("taxes"),
|
func.sum(Revenu.taxes).filter(flux_mois).label("taxes"),
|
||||||
func.sum(Revenu.provisions).label("provisions"),
|
func.sum(Revenu.provisions).filter(flux_mois).label("provisions"),
|
||||||
func.sum(Revenu.total).label("total"),
|
func.sum(Revenu.total).filter(flux_mois).label("total"),
|
||||||
func.sum(Revenu.regles).label("regles"),
|
func.sum(Revenu.regles).label("regles"),
|
||||||
func.sum(Revenu.impayes).label("impayes"),
|
func.sum(Revenu.impayes).label("impayes"),
|
||||||
)
|
)
|
||||||
.join(Document, Revenu.document_id == Document.id)
|
.join(Document, Revenu.document_id == Document.id)
|
||||||
.join(Lot, Revenu.lot_id == Lot.id)
|
|
||||||
.where(Document.date >= start_date)
|
.where(Document.date >= start_date)
|
||||||
.group_by(func.strftime("%Y-%m", Document.date))
|
.group_by(mois)
|
||||||
.order_by(func.strftime("%Y-%m", Document.date))
|
.order_by(mois)
|
||||||
)
|
)
|
||||||
|
|
||||||
if immeuble_id:
|
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 = []
|
monthly_data = [
|
||||||
for row in session.execute(monthly_stmt):
|
RevenuMonthlyPoint(
|
||||||
monthly_data.append(
|
month=row.month,
|
||||||
RevenuMonthlyPoint(
|
loyers=row.loyers or 0.0,
|
||||||
month=row.month,
|
taxes=row.taxes or 0.0,
|
||||||
loyers=row.loyers or 0.0,
|
provisions=row.provisions or 0.0,
|
||||||
taxes=row.taxes or 0.0,
|
total=row.total or 0.0,
|
||||||
provisions=row.provisions or 0.0,
|
regles=row.regles or 0.0,
|
||||||
total=row.total or 0.0,
|
impayes=row.impayes or 0.0,
|
||||||
regles=row.regles or 0.0,
|
|
||||||
impayes=row.impayes or 0.0,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
for row in session.execute(monthly_stmt)
|
||||||
|
]
|
||||||
|
|
||||||
# ========== By Immeuble ==========
|
# ========== By Immeuble ==========
|
||||||
immeuble_stmt = (
|
# Seuls les immeubles ayant produit des revenus sur la periode y figurent.
|
||||||
select(
|
immeuble_stmt = _immeuble_stmt(start_date).order_by(desc("facture"))
|
||||||
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"))
|
|
||||||
)
|
|
||||||
|
|
||||||
if immeuble_id:
|
if immeuble_id:
|
||||||
immeuble_stmt = immeuble_stmt.where(Immeuble.id == immeuble_id)
|
immeuble_stmt = immeuble_stmt.where(Immeuble.id == immeuble_id)
|
||||||
|
|
||||||
by_immeuble = []
|
by_immeuble = [
|
||||||
for row in session.execute(immeuble_stmt):
|
_immeuble_response(row)
|
||||||
rev = row.total_revenus or 0.0
|
for row in session.execute(immeuble_stmt)
|
||||||
reg = row.total_regles or 0.0
|
if row.facture is not None
|
||||||
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),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# ========== Top impayes by locataire ==========
|
# ========== 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 = (
|
impayes_stmt = (
|
||||||
select(
|
select(
|
||||||
Locataire.id,
|
Locataire.id,
|
||||||
@@ -292,39 +336,41 @@ async def get_revenus_summary(
|
|||||||
Lot.numero,
|
Lot.numero,
|
||||||
Immeuble.code,
|
Immeuble.code,
|
||||||
Immeuble.adresse,
|
Immeuble.adresse,
|
||||||
func.sum(Revenu.total).label("total_revenus"),
|
flux_locataire.c.facture,
|
||||||
func.sum(Revenu.regles).label("total_regles"),
|
flux_locataire.c.encaisse,
|
||||||
func.sum(Revenu.impayes).label("total_impayes"),
|
dette.c.restant_du,
|
||||||
func.count(Revenu.id).label("nb_mois"),
|
func.count(func.distinct(Revenu.document_id)).label("nb_mois"),
|
||||||
)
|
)
|
||||||
.join(Lot, Revenu.lot_id == Lot.id)
|
.select_from(Locataire)
|
||||||
.join(Locataire, Revenu.locataire_id == Locataire.id)
|
.join(dette, dette.c.cle == Locataire.id)
|
||||||
|
.join(Lot, Locataire.lot_id == Lot.id)
|
||||||
.join(Immeuble, Lot.immeuble_id == Immeuble.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)
|
.group_by(Locataire.id)
|
||||||
.having(func.sum(Revenu.impayes) > 0)
|
.order_by(desc("restant_du"))
|
||||||
.order_by(desc("total_impayes"))
|
|
||||||
.limit(10)
|
.limit(10)
|
||||||
)
|
)
|
||||||
|
|
||||||
if immeuble_id:
|
if immeuble_id:
|
||||||
impayes_stmt = impayes_stmt.where(Lot.immeuble_id == immeuble_id)
|
impayes_stmt = impayes_stmt.where(Lot.immeuble_id == immeuble_id)
|
||||||
|
|
||||||
top_impayes = []
|
top_impayes = [
|
||||||
for row in session.execute(impayes_stmt):
|
RevenuByLocataire(
|
||||||
top_impayes.append(
|
locataire_id=row.id,
|
||||||
RevenuByLocataire(
|
locataire_nom=row.nom,
|
||||||
locataire_id=row.id,
|
lot_numero=row.numero,
|
||||||
locataire_nom=row.nom,
|
immeuble_code=row.code,
|
||||||
lot_numero=row.numero,
|
immeuble_adresse=row.adresse,
|
||||||
immeuble_code=row.code,
|
date_debut=str(row.date_debut) if row.date_debut else None,
|
||||||
immeuble_adresse=row.adresse,
|
total_revenus=row.facture or 0.0,
|
||||||
date_debut=str(row.date_debut) if row.date_debut else None,
|
total_regles=row.encaisse or 0.0,
|
||||||
total_revenus=row.total_revenus or 0.0,
|
total_impayes=row.restant_du or 0.0,
|
||||||
total_regles=row.total_regles or 0.0,
|
nb_mois=row.nb_mois or 0,
|
||||||
total_impayes=row.total_impayes or 0.0,
|
|
||||||
nb_mois=row.nb_mois or 0,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
for row in session.execute(impayes_stmt)
|
||||||
|
]
|
||||||
|
|
||||||
return RevenusSummaryResponse(
|
return RevenusSummaryResponse(
|
||||||
kpis=kpis,
|
kpis=kpis,
|
||||||
@@ -341,6 +387,8 @@ async def get_revenus_by_lot(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[RevenuByLot]:
|
) -> list[RevenuByLot]:
|
||||||
"""Retourne les revenus agreges par lot."""
|
"""Retourne les revenus agreges par lot."""
|
||||||
|
flux = flux_par(Revenu.lot_id)
|
||||||
|
dette = restant_du_par(Revenu.lot_id)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
Lot.id,
|
Lot.id,
|
||||||
@@ -348,9 +396,9 @@ async def get_revenus_by_lot(
|
|||||||
Lot.type,
|
Lot.type,
|
||||||
Immeuble.code,
|
Immeuble.code,
|
||||||
func.max(Locataire.nom).label("locataire_nom"),
|
func.max(Locataire.nom).label("locataire_nom"),
|
||||||
func.sum(Revenu.total).label("total_revenus"),
|
flux.c.facture,
|
||||||
func.sum(Revenu.regles).label("total_regles"),
|
flux.c.encaisse,
|
||||||
func.sum(Revenu.impayes).label("total_impayes"),
|
dette.c.restant_du,
|
||||||
func.max(Document.date).label("derniere_date"),
|
func.max(Document.date).label("derniere_date"),
|
||||||
)
|
)
|
||||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||||
@@ -360,31 +408,30 @@ async def get_revenus_by_lot(
|
|||||||
Locataire,
|
Locataire,
|
||||||
and_(Locataire.lot_id == Lot.id, Locataire.date_fin.is_(None)),
|
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)
|
.group_by(Lot.id)
|
||||||
.order_by(desc("total_impayes"), desc("total_revenus"))
|
.order_by(desc("restant_du"), desc("facture"))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|
||||||
if immeuble_id:
|
if immeuble_id:
|
||||||
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
||||||
|
|
||||||
results = []
|
return [
|
||||||
for row in session.execute(stmt):
|
RevenuByLot(
|
||||||
results.append(
|
lot_id=row.id,
|
||||||
RevenuByLot(
|
lot_numero=row.numero,
|
||||||
lot_id=row.id,
|
lot_type=row.type,
|
||||||
lot_numero=row.numero,
|
immeuble_code=row.code,
|
||||||
lot_type=row.type,
|
locataire_nom=row.locataire_nom,
|
||||||
immeuble_code=row.code,
|
total_revenus=row.facture or 0.0,
|
||||||
locataire_nom=row.locataire_nom,
|
total_regles=row.encaisse or 0.0,
|
||||||
total_revenus=row.total_revenus or 0.0,
|
total_impayes=row.restant_du or 0.0,
|
||||||
total_regles=row.total_regles or 0.0,
|
derniere_date=str(row.derniere_date) if row.derniere_date else None,
|
||||||
total_impayes=row.total_impayes or 0.0,
|
|
||||||
derniere_date=str(row.derniere_date) if row.derniere_date else None,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
for row in session.execute(stmt)
|
||||||
return results
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/details", response_model=list[RevenuDetailResponse])
|
@router.get("/details", response_model=list[RevenuDetailResponse])
|
||||||
@@ -471,43 +518,7 @@ async def get_immeubles_with_revenus(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[RevenuByImmeuble]:
|
) -> list[RevenuByImmeuble]:
|
||||||
"""Retourne la liste des immeubles avec leurs stats de revenus."""
|
"""Retourne la liste des immeubles avec leurs stats de revenus."""
|
||||||
stmt = (
|
# Tous les immeubles sont listes, y compris ceux sans aucun revenu.
|
||||||
select(
|
stmt = _immeuble_stmt().order_by(Immeuble.code)
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
results = []
|
return [_immeuble_response(row) for row in session.execute(stmt)]
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
"""Tags routes - Tag management and prediction."""
|
"""Tags routes - Tag management and prediction."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ...database import DatabaseService, get_session
|
from ...database import DatabaseService, get_session
|
||||||
|
from ...database.models import Tag
|
||||||
from ...services.tag_predictor import TagPredictor
|
from ...services.tag_predictor import TagPredictor
|
||||||
from ..schemas import PredictTagsRequest
|
from ..schemas import PredictTagsRequest
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["tags"])
|
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")
|
@router.get("/tags")
|
||||||
async def list_tags(
|
async def list_tags(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -21,7 +40,47 @@ async def list_tags(
|
|||||||
db_service = DatabaseService(session)
|
db_service = DatabaseService(session)
|
||||||
tags = db_service.list_tags()
|
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")
|
@router.post("/predict-tags")
|
||||||
|
|||||||
@@ -403,77 +403,8 @@ class DatabaseService:
|
|||||||
if depense.tag_id is not None
|
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]:
|
def list_tags(self) -> list[Tag]:
|
||||||
"""List all available tags."""
|
"""List all available tags."""
|
||||||
stmt = select(Tag).order_by(Tag.nom)
|
stmt = select(Tag).order_by(Tag.nom)
|
||||||
result = self.session.execute(stmt)
|
result = self.session.execute(stmt)
|
||||||
return list(result.scalars().all())
|
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
|
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:
|
def read_pdf(relative_path: str, storage_root: Path | None = None) -> bytes:
|
||||||
"""Read PDF content from storage.
|
"""Read PDF content from storage.
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
"""Orchestrateur principal pour l'extraction des comptes rendus de gérance."""
|
"""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.locataires_table import extract_situation_locataires_from_pdf
|
||||||
from .parsers.metadata import extract_metadata
|
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.operations_table import extract_recapitulatif_operations_from_pdf
|
||||||
from .parsers.pdf import read_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:
|
def extract_compte_rendu(pdf_path: str) -> dict:
|
||||||
@@ -29,28 +27,23 @@ def extract_compte_rendu(pdf_path: str) -> dict:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: Si le fichier PDF n'existe pas
|
FileNotFoundError: Si le fichier PDF n'existe pas
|
||||||
|
ExtractionError: Si aucun lot n'a pu être lu
|
||||||
"""
|
"""
|
||||||
content = read_pdf(pdf_path)
|
content = read_pdf(pdf_path)
|
||||||
|
|
||||||
# Extraction des locataires par cellules de tableau (géométrique) : robuste aux
|
# Un compte rendu sans aucun lot n'existe pas : c'est le signe que le tableau
|
||||||
# colonnes vides et aux lignes mal alignées. Repli sur l'ancien parseur texte si
|
# n'a pas été reconnu (PDF d'un autre type, mise en page inconnue, scan
|
||||||
# le tableau n'a pas de filets détectables ou en cas d'erreur inattendue.
|
# image). Échouer ici vaut mieux qu'enregistrer un document vide, qui ne se
|
||||||
try:
|
# découvrirait qu'au moment de relire les chiffres.
|
||||||
situation = extract_situation_locataires_from_pdf(pdf_path)
|
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:
|
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.
|
# À l'inverse, un mois sans aucune dépense reste plausible : liste vide admise.
|
||||||
try:
|
operations = extract_recapitulatif_operations_from_pdf(pdf_path)
|
||||||
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 {
|
return {
|
||||||
"metadata": extract_metadata(content.text, content.words),
|
"metadata": extract_metadata(content.text, content.words),
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
"""Parsers pour les différentes sections des PDFs de gérance."""
|
"""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 .metadata import extract_metadata
|
||||||
from .operations import extract_recapitulatif_operations
|
from .operations_table import extract_recapitulatif_operations_from_pdf
|
||||||
from .pdf import PdfContent, extract_text_from_pdf, read_pdf
|
from .pdf import PdfContent, read_pdf
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PdfContent",
|
"PdfContent",
|
||||||
"read_pdf",
|
"read_pdf",
|
||||||
"extract_text_from_pdf",
|
|
||||||
"extract_metadata",
|
"extract_metadata",
|
||||||
"extract_situation_locataires",
|
"extract_situation_locataires_from_pdf",
|
||||||
"extract_recapitulatif_operations",
|
"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
|
import pdfplumber
|
||||||
|
|
||||||
from ..utils.amounts import extract_amounts_from_line
|
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
|
_Y_TOL = 3.0
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ def extract_recapitulatif_operations_from_pdf(pdf_path: str) -> list[dict]:
|
|||||||
"fournisseur": current_fournisseur,
|
"fournisseur": current_fournisseur,
|
||||||
"description": desc,
|
"description": desc,
|
||||||
"lot_concerne": None,
|
"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},
|
"montants": {k: (montants[k] or 0.0) for k in _AMOUNT_KEYS},
|
||||||
"_block": block_id,
|
"_block": block_id,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,18 +57,3 @@ def read_pdf(pdf_path: str) -> PdfContent:
|
|||||||
]
|
]
|
||||||
|
|
||||||
return PdfContent(text="\n".join(text_parts), words=header_words)
|
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)
|
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]:
|
def normalize_extraction_lots(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Normalise, sur place, tous les numéros de lot d'une extraction.
|
"""Normalise, sur place, tous les numéros de lot d'une extraction.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ import pytest
|
|||||||
from plesna_gerance.database import connection
|
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
|
@pytest.fixture
|
||||||
def db_session(tmp_path, monkeypatch):
|
def db_session(tmp_path, monkeypatch):
|
||||||
"""Session SQLAlchemy sur une base SQLite temporaire et isolée.
|
"""Session SQLAlchemy sur une base SQLite temporaire et isolée.
|
||||||
@@ -28,6 +44,27 @@ def db_session(tmp_path, monkeypatch):
|
|||||||
connection.reset_connection()
|
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
|
@pytest.fixture
|
||||||
def sample_data():
|
def sample_data():
|
||||||
"""Données extraites minimales mais complètes pour save_document."""
|
"""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
|
import pytest
|
||||||
|
|
||||||
from plesna_gerance.parsers.operations import _extract_lot_code_from_description
|
from plesna_gerance.utils.lots import (
|
||||||
from plesna_gerance.utils.lots import normalize_extraction_lots, normalize_lot_numero
|
extract_lot_numero_from_description,
|
||||||
|
normalize_extraction_lots,
|
||||||
|
normalize_lot_numero,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -44,8 +47,8 @@ def test_normalize_lot_numero(raw, expected):
|
|||||||
("", None),
|
("", None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_extract_lot_code_from_description(description, expected):
|
def test_extract_lot_numero_from_description(description, expected):
|
||||||
assert _extract_lot_code_from_description(description) == expected
|
assert extract_lot_numero_from_description(description) == expected
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_extraction_lots():
|
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