Files
pdf_oralia_vibe/src/plesna_gerance/database/service.py
Bertrand Benjamin 420a856e41 feat: normalise les numéros de lot sur 2 chiffres
Les PDF et les saisies manuelles écrivent le même lot de plusieurs façons
("6", "06", "0006"), et chaque forme créait jusqu'ici un lot distinct en
base. utils/lots.py fixe la forme canonique sur 2 chiffres et sert de point
d'entrée unique pour la normalisation.

Elle est appliquée à la source dans les parseurs (locataires texte et
tableau, codes lot des opérations), et en dernier recours dans
get_or_create_lot et save_document, pour couvrir les extractions éditées à
la main via l'API. Les numéros à plus de 2 chiffres significatifs ne sont
pas tronqués.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:46:33 +02:00

418 lines
15 KiB
Python

"""Database service for saving and querying extracted data."""
import json
from datetime import date, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..utils.amounts import parse_amount
from ..utils.lots import (
LOT_NUMERO_INCONNU,
normalize_extraction_lots,
normalize_lot_numero,
)
from . import storage
from .models import Depense, Document, Immeuble, Locataire, Lot, Revenu, Tag
class DuplicateDocumentError(Exception):
"""Raised when attempting to save a document that already exists."""
def __init__(self, reference: str, doc_date: date):
self.reference = reference
self.date = doc_date
super().__init__(
f"Document avec reference={reference} et date={doc_date} existe deja"
)
class DatabaseService:
"""Service for database operations."""
def __init__(self, session: Session):
self.session = session
def check_duplicate(self, reference: str, doc_date: date) -> Document | None:
"""Check if a document with this reference and date already exists."""
stmt = select(Document).where(
Document.reference == reference, Document.date == doc_date
)
return self.session.execute(stmt).scalar_one_or_none()
def get_or_create_immeuble(
self, code: str, adresse: str = None, ville: str = None, code_postal: str = None
) -> Immeuble:
"""Get existing immeuble or create new one."""
stmt = select(Immeuble).where(Immeuble.code == code)
immeuble = self.session.execute(stmt).scalar_one_or_none()
if immeuble is None:
immeuble = Immeuble(
code=code, adresse=adresse, ville=ville, code_postal=code_postal
)
self.session.add(immeuble)
self.session.flush() # Get the ID
return immeuble
def get_or_create_lot(
self, immeuble_id: int, numero: str, lot_type: str = None
) -> Lot:
"""Get existing lot or create new one.
Le numero est normalise sur 2 chiffres pour qu'un meme lot saisi "6",
"06" ou "0006" ne soit pas duplique.
"""
numero = normalize_lot_numero(numero) or LOT_NUMERO_INCONNU
stmt = select(Lot).where(Lot.immeuble_id == immeuble_id, Lot.numero == numero)
lot = self.session.execute(stmt).scalar_one_or_none()
if lot is None:
lot = Lot(immeuble_id=immeuble_id, numero=numero, type=lot_type)
self.session.add(lot)
self.session.flush()
return lot
def get_or_create_locataire(
self, lot_id: int, nom: str, date_debut: date = None
) -> Locataire:
"""Get existing locataire or create new one."""
stmt = select(Locataire).where(Locataire.lot_id == lot_id, Locataire.nom == nom)
# If date_debut is provided, include it in the search
if date_debut:
stmt = stmt.where(Locataire.date_debut == date_debut)
else:
stmt = stmt.where(Locataire.date_debut.is_(None))
locataire = self.session.execute(stmt).scalar_one_or_none()
if locataire is None:
locataire = Locataire(lot_id=lot_id, nom=nom, date_debut=date_debut)
self.session.add(locataire)
self.session.flush()
return locataire
def _parse_date(self, date_str: str | None) -> date | None:
"""Parse ISO date string to date object."""
if not date_str:
return None
try:
return datetime.strptime(date_str, "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
@staticmethod
def _normalize_amount(value: Any) -> float | None:
"""Normalise un montant en float (ou None si non interprétable).
Garantit qu'un type non numérique issu de l'extraction (string, dict…)
n'entre jamais en base dans une colonne Float.
"""
if isinstance(value, bool): # bool est un int en Python, on l'exclut
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
return parse_amount(value)
return None
def save_document(
self,
data: dict[str, Any],
source_file: str = None,
pdf_content: bytes = None,
depenses_tags: list[dict] = None,
overwrite: bool = False,
) -> Document:
"""Save extracted JSON data to database.
Args:
data: The 'data' portion of the extracted JSON (contains metadata,
situation_locataires, recapitulatif_operations)
source_file: Original PDF filename
pdf_content: Binary content of the PDF file (optional, for storage)
depenses_tags: List of tags for expenses
overwrite: If True, delete existing document and recreate it
Returns:
The created Document instance
Raises:
DuplicateDocumentError: If document already exists and overwrite=False
"""
# Uniformiser les numéros de lot avant toute persistance (JSON + tables)
normalize_extraction_lots(data)
metadata = data.get("metadata", {})
doc_info = metadata.get("document", {})
immeuble_info = metadata.get("immeuble", {})
editeur_info = metadata.get("editeur", {})
solde_info = metadata.get("solde", {})
# Extract key fields
reference = doc_info.get("reference", "")
doc_date = self._parse_date(doc_info.get("date"))
if not reference or not doc_date:
raise ValueError("Document must have reference and date")
# Check for duplicates and preserve existing file paths if overwriting
existing_pdf_path = None
existing_json_path = None
existing = self.check_duplicate(reference, doc_date)
if existing:
if overwrite:
# Preserve existing file paths for reuse
existing_pdf_path = existing.pdf_path
existing_json_path = existing.json_path
# Delete existing document (cascade will delete related data)
# But DON'T delete files - we'll reuse or update them
self.session.delete(existing)
self.session.flush()
else:
raise DuplicateDocumentError(reference, doc_date)
# Get or create immeuble
immeuble = self.get_or_create_immeuble(
code=immeuble_info.get("code", "UNKNOWN"),
adresse=immeuble_info.get("adresse"),
ville=immeuble_info.get("ville"),
code_postal=immeuble_info.get("code_postal"),
)
# Compute storage paths for PDF and JSON
pdf_path = None
json_path = None
if pdf_content is not None:
# New PDF provided - compute new paths
pdf_path, json_path = storage.compute_document_paths(
reference=reference,
doc_date=doc_date,
immeuble_adresse=immeuble.adresse,
)
# Delete old files if paths are different
if existing_pdf_path and existing_pdf_path != pdf_path:
storage.delete_document_files(existing_pdf_path, None)
if existing_json_path and existing_json_path != json_path:
storage.delete_document_files(None, existing_json_path)
elif existing_json_path:
# No new PDF but we have existing paths - preserve them
pdf_path = existing_pdf_path
json_path = existing_json_path
# Create document
document = Document(
reference=reference,
date=doc_date,
type=doc_info.get("type"),
source_file=source_file,
immeuble_id=immeuble.id,
json_data=json.dumps(data, ensure_ascii=False, default=str),
editeur_nom=editeur_info.get("nom"),
editeur_siret=editeur_info.get("siret"),
solde_montant=self._normalize_amount(solde_info.get("montant")),
solde_type=solde_info.get("type"),
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
pdf_path=pdf_path,
json_path=json_path,
)
self.session.add(document)
self.session.flush()
# Save files to storage
if pdf_content is not None and pdf_path and json_path:
# New PDF provided - save both files
storage.save_pdf(pdf_content, pdf_path)
storage.save_json(data, json_path)
elif json_path:
# No new PDF but we have a json_path - update the JSON file
storage.save_json(data, json_path)
# Process situation_locataires (revenus)
for situation in data.get("situation_locataires", []):
self._save_situation_locataire(document, immeuble, situation)
# Process recapitulatif_operations (depenses)
# Créer un mapping index -> tag_id si des tags sont fournis
tag_mapping = {}
if depenses_tags:
for item in depenses_tags:
tag_mapping[item.get("index")] = item.get("tag_id")
for idx, operation in enumerate(data.get("recapitulatif_operations", [])):
tag_id = tag_mapping.get(idx)
self._save_operation(document, immeuble, operation, tag_id=tag_id)
self.session.commit()
return document
def _save_situation_locataire(
self, document: Document, immeuble: Immeuble, situation: dict
) -> None:
"""Save locataire situation (revenus lines)."""
lot_info = situation.get("lot", {})
locataire_info = situation.get("locataire", {})
# Get or create lot
lot = self.get_or_create_lot(
immeuble_id=immeuble.id,
numero=lot_info.get("numero") or LOT_NUMERO_INCONNU,
lot_type=lot_info.get("type"),
)
# Get or create locataire
locataire = self.get_or_create_locataire(
lot_id=lot.id, nom=locataire_info.get("nom", "INCONNU")
)
# Save each revenue line
for ligne in situation.get("lignes", []):
periode = ligne.get("periode", {})
divers = ligne.get("divers", {})
revenu = Revenu(
document_id=document.id,
lot_id=lot.id,
locataire_id=locataire.id,
type_ligne=ligne.get("type", "loyer"),
periode_debut=self._parse_date(periode.get("debut")),
periode_fin=self._parse_date(periode.get("fin")),
loyers=ligne.get("loyers", 0.0) or 0.0,
taxes=ligne.get("taxes", 0.0) or 0.0,
provisions=ligne.get("provisions", 0.0) or 0.0,
divers_montant=divers.get("montant", 0.0) or 0.0 if divers else 0.0,
divers_libelle=divers.get("libelle") if divers else None,
total=ligne.get("total", 0.0) or 0.0,
regles=ligne.get("regles", 0.0) or 0.0,
impayes=ligne.get("impayes", 0.0) or 0.0,
)
self.session.add(revenu)
def _save_operation(
self,
document: Document,
immeuble: Immeuble,
operation: dict,
tag_id: int = None,
) -> None:
"""Save operation (depense)."""
montants = operation.get("montants", {})
# Check if operation is linked to a specific lot
lot_id = None
lot_numero = operation.get("lot_numero")
if lot_numero:
lot = self.get_or_create_lot(immeuble_id=immeuble.id, numero=lot_numero)
lot_id = lot.id
depense = Depense(
document_id=document.id,
immeuble_id=immeuble.id,
lot_id=lot_id, # Can be NULL for immeuble-level expenses
tag_id=tag_id, # Tag assigné manuellement
categorie=operation.get("categorie"),
sous_categorie=operation.get("sous_categorie"),
fournisseur=operation.get("fournisseur"),
description=operation.get("description"),
debit=montants.get("debit", 0.0) or 0.0,
credit=montants.get("credit", 0.0) or 0.0,
tva=montants.get("tva", 0.0) or 0.0,
locatif=montants.get("locatif", 0.0) or 0.0,
deductible=montants.get("deductible", 0.0) or 0.0,
)
self.session.add(depense)
def list_documents(self, limit: int = 100, offset: int = 0) -> list[Document]:
"""List all imported documents."""
stmt = (
select(Document)
.order_by(Document.date.desc(), Document.created_at.desc())
.limit(limit)
.offset(offset)
)
result = self.session.execute(stmt)
return list(result.scalars().all())
def get_document_by_id(self, doc_id: int) -> Document | None:
"""Get a document by ID."""
return self.session.get(Document, doc_id)
def get_revenus_summary(
self, immeuble_id: int = None, year: int = None
) -> list[dict]:
"""Get revenue summary grouped by period."""
stmt = select(Revenu)
if immeuble_id:
stmt = stmt.join(Lot).where(Lot.immeuble_id == immeuble_id)
if year:
stmt = stmt.where(
Revenu.periode_debut >= date(year, 1, 1),
Revenu.periode_debut <= date(year, 12, 31),
)
result = self.session.execute(stmt)
revenus = result.scalars().all()
# Aggregate
total_loyers = sum(r.loyers for r in revenus)
total_regles = sum(r.regles for r in revenus)
total_impayes = sum(r.impayes for r in revenus)
return {
"total_loyers": total_loyers,
"total_regles": total_regles,
"total_impayes": total_impayes,
"count": len(revenus),
}
def get_depenses_summary(self, immeuble_id: int = None, year: int = None) -> dict:
"""Get expenses summary grouped by category."""
stmt = select(Depense)
if immeuble_id:
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
if year:
stmt = stmt.join(Document).where(
Document.date >= date(year, 1, 1), Document.date <= date(year, 12, 31)
)
result = self.session.execute(stmt)
depenses = result.scalars().all()
# Aggregate by category
by_category = {}
for d in depenses:
cat = d.categorie or "AUTRE"
if cat not in by_category:
by_category[cat] = {"debit": 0.0, "credit": 0.0, "count": 0}
by_category[cat]["debit"] += d.debit
by_category[cat]["credit"] += d.credit
by_category[cat]["count"] += 1
total_debit = sum(d.debit for d in depenses)
total_credit = sum(d.credit for d in depenses)
return {
"by_category": by_category,
"total_debit": total_debit,
"total_credit": total_credit,
"count": len(depenses),
}
def list_tags(self) -> list[Tag]:
"""List all available tags."""
stmt = select(Tag).order_by(Tag.nom)
result = self.session.execute(stmt)
return list(result.scalars().all())
def get_tag_by_id(self, tag_id: int) -> Tag | None:
"""Get a tag by ID."""
return self.session.get(Tag, tag_id)