feat: add depense tagging

This commit is contained in:
2026-01-19 05:19:11 +01:00
parent c5e51a7513
commit 0fd6bdaeb3
8 changed files with 711 additions and 43 deletions

View File

@@ -0,0 +1,239 @@
<template>
<div class="flex flex-col h-full bg-gray-50">
<!-- Header -->
<div class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-gray-900">Tagging des dépenses</h2>
<span v-if="isDuplicate" class="px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded">
Écrasement
</span>
</div>
<p class="text-sm text-gray-600 mt-1">
{{ taggedCount }} / {{ depenses.length }} dépenses taggées
<span v-if="predictions.length > 0" class="text-green-600 ml-2">
({{ predictedCount }} pré-remplies automatiquement)
</span>
</p>
<p v-if="isDuplicate" class="text-xs text-orange-600 mt-1">
Les données existantes seront remplacées lors de la validation
</p>
</div>
<!-- Loading state -->
<div v-if="isLoadingPredictions" class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p class="text-gray-600">Analyse en cours...</p>
</div>
</div>
<!-- List of expenses to tag -->
<div v-else class="flex-1 overflow-y-auto p-4 space-y-3">
<div
v-for="(depense, index) in depenses"
:key="index"
class="bg-white rounded-lg shadow-sm border border-gray-200 p-4"
>
<!-- Expense info -->
<div class="flex items-start justify-between gap-4 mb-3">
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 truncate">
{{ depense.fournisseur || 'Fournisseur inconnu' }}
</div>
<div class="text-sm text-gray-600 truncate">
{{ depense.sous_categorie || depense.description || 'Aucune description' }}
</div>
<div class="text-sm text-gray-500 mt-1">
Montant: {{ formatMontant(depense.montants?.debit || 0) }}
</div>
</div>
<!-- Tag selector -->
<div class="flex-shrink-0 w-48">
<select
v-model="selectedTags[index]"
@change="onTagChange(index)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
:class="{
'bg-green-50 border-green-300': selectedTags[index] && predictions[index]?.tag_id,
'bg-yellow-50 border-yellow-300': !selectedTags[index]
}"
>
<option :value="null">-- Sélectionner un tag --</option>
<option
v-for="tag in availableTags"
:key="tag.id"
:value="tag.id"
>
{{ tag.nom }}
</option>
</select>
</div>
</div>
<!-- Prediction info -->
<div
v-if="predictions[index] && predictions[index].tag_id"
class="flex items-center gap-2 text-xs text-gray-600 bg-blue-50 border border-blue-200 rounded px-3 py-2"
>
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
Confiance: {{ predictions[index].confidence }}% - {{ predictions[index].reason }}
</span>
</div>
<!-- No prediction warning -->
<div
v-else-if="predictions[index] && !predictions[index].tag_id && !selectedTags[index]"
class="flex items-center gap-2 text-xs text-gray-600 bg-gray-50 border border-gray-200 rounded px-3 py-2"
>
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{{ predictions[index].reason }}</span>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="flex-shrink-0 p-4 bg-white border-t border-gray-200">
<div class="flex items-center justify-between gap-4">
<button
@click="$emit('cancel')"
class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Annuler
</button>
<div class="flex items-center gap-3">
<span v-if="taggedCount < depenses.length" class="text-sm text-yellow-600">
{{ depenses.length - taggedCount }} dépense(s) sans tag
</span>
<button
@click="validateAndSave"
:disabled="taggedCount === 0"
class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Valider et enregistrer
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
depenses: {
type: Array,
required: true
},
isDuplicate: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['save', 'cancel'])
const availableTags = ref([])
const predictions = ref([])
const selectedTags = ref({})
const isLoadingPredictions = ref(false)
const taggedCount = computed(() => {
return Object.values(selectedTags.value).filter(tag => tag !== null && tag !== undefined).length
})
const predictedCount = computed(() => {
return predictions.value.filter(p => p.tag_id !== null).length
})
onMounted(async () => {
await loadTags()
await loadPredictions()
})
async function loadTags() {
try {
const response = await fetch('/api/tags')
if (!response.ok) throw new Error('Erreur lors du chargement des tags')
availableTags.value = await response.json()
} catch (err) {
console.error('Error loading tags:', err)
alert('Erreur: ' + err.message)
}
}
async function loadPredictions() {
isLoadingPredictions.value = true
try {
const response = await fetch('/api/predict-tags', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
depenses: props.depenses
})
})
if (!response.ok) throw new Error('Erreur lors de la prédiction')
const data = await response.json()
predictions.value = data.predictions
// Pre-fill tags with predictions
data.predictions.forEach(pred => {
if (pred.tag_id !== null) {
selectedTags.value[pred.index] = pred.tag_id
}
})
} catch (err) {
console.error('Error loading predictions:', err)
} finally {
isLoadingPredictions.value = false
}
}
function onTagChange(index) {
// Just trigger reactivity
selectedTags.value = { ...selectedTags.value }
}
function validateAndSave() {
// If duplicate, ask for confirmation
if (props.isDuplicate) {
const confirmed = confirm(
'⚠️ Ce document existe déjà en base.\n\n' +
'Voulez-vous vraiment écraser les données existantes ?\n\n' +
'Les anciennes données seront définitivement supprimées.'
)
if (!confirmed) {
return
}
}
// Convert selectedTags to array format expected by backend
const depensesTagsArray = Object.entries(selectedTags.value)
.filter(([_, tagId]) => tagId !== null && tagId !== undefined)
.map(([index, tagId]) => ({
index: parseInt(index),
tag_id: tagId
}))
emit('save', depensesTagsArray, props.isDuplicate)
}
function formatMontant(value) {
return new Intl.NumberFormat('fr-FR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value)
}
</script>

View File

@@ -38,10 +38,10 @@
/> />
</div> </div>
<!-- Right panel: JSON Viewer --> <!-- Right panel: JSON Viewer or Tagging Step -->
<div class="w-1/2 flex flex-col bg-gray-50"> <div class="w-1/2 flex flex-col bg-gray-50">
<!-- Extract button bar --> <!-- Extract button bar -->
<div v-if="pdfFile && !extractedData && !isExtracting" class="flex-shrink-0 p-4 bg-white border-b border-gray-200"> <div v-if="pdfFile && !extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<button <button
@click="extractCurrentFile" @click="extractCurrentFile"
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium" class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
@@ -50,42 +50,62 @@
</button> </button>
</div> </div>
<!-- Save button bar (when data is extracted) --> <!-- Continue to tagging button (when data is extracted) -->
<div v-if="extractedData && !isExtracting" class="flex-shrink-0 p-4 bg-white border-b border-gray-200"> <div v-if="extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<div class="flex items-center gap-3"> <!-- Duplicate warning -->
<button <div v-if="saveMessage && saveMessage.type === 'duplicate'" class="mb-3">
@click="saveToDatabase" <div class="px-4 py-3 rounded-lg text-sm bg-yellow-100 text-yellow-800 border border-yellow-200">
:disabled="isSaving" <div class="font-medium flex items-center gap-2">
class="flex-1 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed" <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<span v-if="isSaving">Sauvegarde en cours...</span> </svg>
<span v-else>Sauvegarder en base</span> {{ saveMessage.title }}
</button> </div>
<div v-if="saveMessage.details" class="mt-1 text-xs opacity-90">{{ saveMessage.details }}</div>
</div>
</div> </div>
<!-- Save feedback message --> <button
<div v-if="saveMessage" class="mt-3"> @click="goToTagging"
<div class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
:class="[ >
'px-4 py-3 rounded-lg text-sm', Continuer vers le tagging
saveMessage.type === 'success' ? 'bg-green-100 text-green-800 border border-green-200' : '', </button>
saveMessage.type === 'duplicate' ? 'bg-yellow-100 text-yellow-800 border border-yellow-200' : '', </div>
saveMessage.type === 'error' ? 'bg-red-100 text-red-800 border border-red-200' : ''
]" <!-- Tagging step -->
> <TaggingStep
<div class="font-medium">{{ saveMessage.title }}</div> v-if="showTagging && extractedData"
<div v-if="saveMessage.details" class="mt-1 text-xs opacity-80">{{ saveMessage.details }}</div> :depenses="extractedData.data.recapitulatif_operations"
</div> :is-duplicate="isDuplicate"
@save="handleTaggingSave"
@cancel="showTagging = false"
class="flex-1"
/>
<!-- Save feedback message -->
<div v-if="saveMessage && showTagging" class="flex-shrink-0 p-4 bg-white border-t border-gray-200">
<div
:class="[
'px-4 py-3 rounded-lg text-sm',
saveMessage.type === 'success' ? 'bg-green-100 text-green-800 border border-green-200' : '',
saveMessage.type === 'duplicate' ? 'bg-yellow-100 text-yellow-800 border border-yellow-200' : '',
saveMessage.type === 'error' ? 'bg-red-100 text-red-800 border border-red-200' : ''
]"
>
<div class="font-medium">{{ saveMessage.title }}</div>
<div v-if="saveMessage.details" class="mt-1 text-xs opacity-80">{{ saveMessage.details }}</div>
</div> </div>
</div> </div>
<!-- Placeholder when no file --> <!-- Placeholder when no file -->
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center text-gray-400"> <div v-if="!pdfFile && !showTagging" class="flex-1 flex items-center justify-center text-gray-400">
<p>Selectionnez un PDF pour commencer</p> <p>Selectionnez un PDF pour commencer</p>
</div> </div>
<!-- JSON Viewer (only shown when not tagging) -->
<JsonViewer <JsonViewer
v-else v-if="pdfFile && !showTagging"
:data="extractedData" :data="extractedData"
:is-loading="isExtracting" :is-loading="isExtracting"
class="flex-1 overflow-hidden" class="flex-1 overflow-hidden"
@@ -100,6 +120,7 @@ import { useRouter } from 'vue-router'
import { pendingFile } from '../store' import { pendingFile } from '../store'
import PdfPreview from '../components/PdfPreview.vue' import PdfPreview from '../components/PdfPreview.vue'
import JsonViewer from '../components/JsonViewer.vue' import JsonViewer from '../components/JsonViewer.vue'
import TaggingStep from '../components/TaggingStep.vue'
const router = useRouter() const router = useRouter()
@@ -110,6 +131,8 @@ const extractedData = ref(null)
const isExtracting = ref(false) const isExtracting = ref(false)
const isSaving = ref(false) const isSaving = ref(false)
const saveMessage = ref(null) const saveMessage = ref(null)
const showTagging = ref(false)
const isDuplicate = ref(false)
// Check for pending file from store on mount // Check for pending file from store on mount
onMounted(() => { onMounted(() => {
@@ -124,12 +147,18 @@ function triggerFileInput() {
fileInput.value?.click() fileInput.value?.click()
} }
function resetState() {
extractedData.value = null
saveMessage.value = null
showTagging.value = false
isDuplicate.value = false
}
function onFileChange(e) { function onFileChange(e) {
const file = e.target.files[0] const file = e.target.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) { if (file && file.name.toLowerCase().endsWith('.pdf')) {
pdfFile.value = file pdfFile.value = file
extractedData.value = null resetState()
saveMessage.value = null
extractCurrentFile() extractCurrentFile()
} }
e.target.value = '' e.target.value = ''
@@ -140,8 +169,7 @@ function onDrop(e) {
const file = e.dataTransfer.files[0] const file = e.dataTransfer.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) { if (file && file.name.toLowerCase().endsWith('.pdf')) {
pdfFile.value = file pdfFile.value = file
extractedData.value = null resetState()
saveMessage.value = null
extractCurrentFile() extractCurrentFile()
} }
} }
@@ -150,6 +178,7 @@ async function extractCurrentFile() {
if (!pdfFile.value) return if (!pdfFile.value) return
isExtracting.value = true isExtracting.value = true
saveMessage.value = null
try { try {
const formData = new FormData() const formData = new FormData()
@@ -167,6 +196,9 @@ async function extractCurrentFile() {
const data = await response.json() const data = await response.json()
extractedData.value = data extractedData.value = data
// Check for duplicate immediately after extraction
await checkDuplicate()
} catch (err) { } catch (err) {
console.error('Extraction error:', err) console.error('Extraction error:', err)
alert('Erreur: ' + err.message) alert('Erreur: ' + err.message)
@@ -175,7 +207,42 @@ async function extractCurrentFile() {
} }
} }
async function saveToDatabase() { async function checkDuplicate() {
if (!extractedData.value) return
try {
const metadata = extractedData.value.data.metadata
const reference = metadata.document?.reference
const date = metadata.document?.date
if (!reference || !date) return
const response = await fetch(`/api/check-duplicate?reference=${encodeURIComponent(reference)}&date=${encodeURIComponent(date)}`)
const result = await response.json()
if (result.exists) {
isDuplicate.value = true
saveMessage.value = {
type: 'duplicate',
title: '⚠️ Document déjà existant en base',
details: `Ce document (Référence: ${reference}, Date: ${date}) a déjà été importé. Vous pouvez continuer pour le réimporter (les anciennes données seront écrasées).`
}
} else {
isDuplicate.value = false
}
} catch (err) {
console.error('Duplicate check error:', err)
isDuplicate.value = false
// Don't block the flow if check fails
}
}
function goToTagging() {
showTagging.value = true
saveMessage.value = null
}
async function handleTaggingSave(depensesTags, shouldOverwrite) {
if (!extractedData.value) return if (!extractedData.value) return
isSaving.value = true isSaving.value = true
@@ -189,7 +256,9 @@ async function saveToDatabase() {
}, },
body: JSON.stringify({ body: JSON.stringify({
source_file: extractedData.value.source_file, source_file: extractedData.value.source_file,
data: extractedData.value.data data: extractedData.value.data,
depenses_tags: depensesTags,
overwrite: shouldOverwrite || false
}) })
}) })

View File

@@ -14,6 +14,7 @@ from .. import __version__
from ..extractor import extract_compte_rendu from ..extractor import extract_compte_rendu
from ..database import init_db, get_session, DatabaseService from ..database import init_db, get_session, DatabaseService
from ..database.service import DuplicateDocumentError from ..database.service import DuplicateDocumentError
from ..services.tag_predictor import TagPredictor
app = FastAPI( app = FastAPI(
title="Plesna Gérance API", title="Plesna Gérance API",
@@ -38,6 +39,14 @@ class SaveRequest(BaseModel):
source_file: str | None = None source_file: str | None = None
data: dict[str, Any] data: dict[str, Any]
depenses_tags: list[dict] | None = None # Liste des tags par index de dépense
overwrite: bool = False # Si True, écrase le document existant
class PredictTagsRequest(BaseModel):
"""Request body for predicting tags."""
depenses: list[dict] # Liste des dépenses à prédire
class SaveResponse(BaseModel): class SaveResponse(BaseModel):
@@ -159,6 +168,8 @@ async def save_document(
- **source_file**: Nom du fichier PDF original (optionnel) - **source_file**: Nom du fichier PDF original (optionnel)
- **data**: Donnees extraites (format identique a la reponse de /api/extract) - **data**: Donnees extraites (format identique a la reponse de /api/extract)
- **depenses_tags**: Liste des tags par index de depense (optionnel)
- **overwrite**: Si True, ecrase le document existant (optionnel)
Retourne un message de succes avec l'ID du document cree, Retourne un message de succes avec l'ID du document cree,
ou une erreur si le document existe deja (doublon). ou une erreur si le document existe deja (doublon).
@@ -166,7 +177,10 @@ async def save_document(
try: try:
db_service = DatabaseService(session) db_service = DatabaseService(session)
document = db_service.save_document( document = db_service.save_document(
data=request.data, source_file=request.source_file data=request.data,
source_file=request.source_file,
depenses_tags=request.depenses_tags,
overwrite=request.overwrite,
) )
return SaveResponse( return SaveResponse(
@@ -328,6 +342,54 @@ async def check_duplicate(
} }
@app.get("/api/tags", tags=["tags"])
async def list_tags(
session: Session = Depends(get_session),
) -> list[dict]:
"""Liste tous les tags disponibles pour le tagging des depenses.
Retourne une liste de tags avec leur id et nom.
"""
db_service = DatabaseService(session)
tags = db_service.list_tags()
return [{"id": tag.id, "nom": tag.nom} for tag in tags]
@app.post("/api/predict-tags", tags=["tags"])
async def predict_tags(
request: PredictTagsRequest,
session: Session = Depends(get_session),
) -> dict:
"""Predit les tags pour une liste de depenses basee sur l'historique.
- **depenses**: Liste des depenses a predire (avec fournisseur, sous_categorie, etc.)
Retourne une liste de predictions avec tag suggere, confiance et raison.
"""
try:
predictor = TagPredictor(session)
predictions = predictor.predict_batch(request.depenses)
return {
"predictions": [
{
"index": idx,
"tag_id": pred.tag_id,
"tag_name": pred.tag_name,
"confidence": pred.confidence,
"reason": pred.reason,
}
for idx, pred in enumerate(predictions)
]
}
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Erreur lors de la prediction: {str(e)}"
)
# Mount static files for production (if dist exists) # Mount static files for production (if dist exists)
if FRONTEND_DIST.exists(): if FRONTEND_DIST.exists():
# Serve static assets # Serve static assets

View File

@@ -24,6 +24,22 @@ class Base(DeclarativeBase):
pass pass
class Tag(Base):
"""Table des tags pour catégoriser les dépenses."""
__tablename__ = "tags"
id = Column(Integer, primary_key=True, autoincrement=True)
nom = Column(String(100), unique=True, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relations
depenses = relationship("Depense", back_populates="tag")
def __repr__(self) -> str:
return f"<Tag(nom={self.nom})>"
class Immeuble(Base): class Immeuble(Base):
"""Table des immeubles gérés.""" """Table des immeubles gérés."""
@@ -201,8 +217,11 @@ class Depense(Base):
lot_id = Column( lot_id = Column(
Integer, ForeignKey("lots.id"), nullable=True Integer, ForeignKey("lots.id"), nullable=True
) # NULL si dépense immeuble ) # NULL si dépense immeuble
tag_id = Column(
Integer, ForeignKey("tags.id"), nullable=True
) # Tag pour catégorisation manuelle
# Catégorisation # Catégorisation (ancienne, conservée pour historique)
categorie = Column(String(100), nullable=True) # DEPENSES_LOCATIVES, etc. categorie = Column(String(100), nullable=True) # DEPENSES_LOCATIVES, etc.
sous_categorie = Column(String(255), nullable=True) # Nettoyage immeuble, etc. sous_categorie = Column(String(255), nullable=True) # Nettoyage immeuble, etc.
fournisseur = Column(String(255), nullable=True) fournisseur = Column(String(255), nullable=True)
@@ -221,12 +240,14 @@ class Depense(Base):
Index("ix_depense_document", "document_id"), Index("ix_depense_document", "document_id"),
Index("ix_depense_immeuble", "immeuble_id"), Index("ix_depense_immeuble", "immeuble_id"),
Index("ix_depense_categorie", "categorie"), Index("ix_depense_categorie", "categorie"),
Index("ix_depense_tag", "tag_id"),
) )
# Relations # Relations
document = relationship("Document", back_populates="depenses") document = relationship("Document", back_populates="depenses")
immeuble = relationship("Immeuble", back_populates="depenses") immeuble = relationship("Immeuble", back_populates="depenses")
lot = relationship("Lot", back_populates="depenses") lot = relationship("Lot", back_populates="depenses")
tag = relationship("Tag", back_populates="depenses")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Depense(categorie={self.categorie}, debit={self.debit})>" return f"<Depense(categorie={self.categorie}, debit={self.debit})>"

View File

@@ -8,7 +8,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense, Tag
class DuplicateDocumentError(Exception): class DuplicateDocumentError(Exception):
@@ -94,19 +94,27 @@ class DatabaseService:
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
def save_document(self, data: dict[str, Any], source_file: str = None) -> Document: def save_document(
self,
data: dict[str, Any],
source_file: str = None,
depenses_tags: list[dict] = None,
overwrite: bool = False,
) -> Document:
"""Save extracted JSON data to database. """Save extracted JSON data to database.
Args: Args:
data: The 'data' portion of the extracted JSON (contains metadata, data: The 'data' portion of the extracted JSON (contains metadata,
situation_locataires, recapitulatif_operations) situation_locataires, recapitulatif_operations)
source_file: Original PDF filename source_file: Original PDF filename
depenses_tags: List of tags for expenses
overwrite: If True, delete existing document and recreate it
Returns: Returns:
The created Document instance The created Document instance
Raises: Raises:
DuplicateDocumentError: If document already exists DuplicateDocumentError: If document already exists and overwrite=False
""" """
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
doc_info = metadata.get("document", {}) doc_info = metadata.get("document", {})
@@ -124,7 +132,12 @@ class DatabaseService:
# Check for duplicates # Check for duplicates
existing = self.check_duplicate(reference, doc_date) existing = self.check_duplicate(reference, doc_date)
if existing: if existing:
raise DuplicateDocumentError(reference, doc_date) if overwrite:
# Delete existing document (cascade will delete related data)
self.session.delete(existing)
self.session.flush()
else:
raise DuplicateDocumentError(reference, doc_date)
# Get or create immeuble # Get or create immeuble
immeuble = self.get_or_create_immeuble( immeuble = self.get_or_create_immeuble(
@@ -156,8 +169,15 @@ class DatabaseService:
self._save_situation_locataire(document, immeuble, situation) self._save_situation_locataire(document, immeuble, situation)
# Process recapitulatif_operations (depenses) # Process recapitulatif_operations (depenses)
for operation in data.get("recapitulatif_operations", []): # Créer un mapping index -> tag_id si des tags sont fournis
self._save_operation(document, immeuble, operation) 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() self.session.commit()
return document return document
@@ -205,7 +225,11 @@ class DatabaseService:
self.session.add(revenu) self.session.add(revenu)
def _save_operation( def _save_operation(
self, document: Document, immeuble: Immeuble, operation: dict self,
document: Document,
immeuble: Immeuble,
operation: dict,
tag_id: int = None,
) -> None: ) -> None:
"""Save operation (depense).""" """Save operation (depense)."""
montants = operation.get("montants", {}) montants = operation.get("montants", {})
@@ -221,6 +245,7 @@ class DatabaseService:
document_id=document.id, document_id=document.id,
immeuble_id=immeuble.id, immeuble_id=immeuble.id,
lot_id=lot_id, # Can be NULL for immeuble-level expenses lot_id=lot_id, # Can be NULL for immeuble-level expenses
tag_id=tag_id, # Tag assigné manuellement
categorie=operation.get("categorie"), categorie=operation.get("categorie"),
sous_categorie=operation.get("sous_categorie"), sous_categorie=operation.get("sous_categorie"),
fournisseur=operation.get("fournisseur"), fournisseur=operation.get("fournisseur"),
@@ -312,3 +337,13 @@ class DatabaseService:
"total_credit": total_credit, "total_credit": total_credit,
"count": len(depenses), "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)

View File

@@ -0,0 +1,76 @@
"""Script pour initialiser les tags prédéfinis dans la base de données."""
from sqlalchemy.exc import IntegrityError
from ..database import init_db, get_session
from ..database.models import Tag
# Liste des tags prédéfinis
PREDEFINED_TAGS = [
"Ascenseur",
"Assurance",
"Contentieux",
"Diagnostics",
"Eau",
"Elec",
"Entretien",
"Hono E/S",
"Hono Gestion",
"Loyer Charge",
"Tel",
"Travaux",
]
def seed_tags():
"""Initialise les tags prédéfinis en base."""
# Initialize database
init_db()
# Get session
session = next(get_session())
try:
created_count = 0
skipped_count = 0
for tag_name in PREDEFINED_TAGS:
try:
# Check if tag already exists
existing = session.query(Tag).filter(Tag.nom == tag_name).first()
if existing:
print(f" ⏭️ Tag '{tag_name}' existe déjà (id={existing.id})")
skipped_count += 1
else:
tag = Tag(nom=tag_name)
session.add(tag)
session.flush()
print(f" ✅ Tag '{tag_name}' créé (id={tag.id})")
created_count += 1
except IntegrityError:
session.rollback()
print(f" ⚠️ Erreur lors de la création du tag '{tag_name}'")
skipped_count += 1
session.commit()
print(f"\n📊 Résumé:")
print(f" - {created_count} tags créés")
print(f" - {skipped_count} tags existants")
print(f" - Total: {len(PREDEFINED_TAGS)} tags")
except Exception as e:
session.rollback()
print(f"❌ Erreur: {e}")
raise
finally:
session.close()
if __name__ == "__main__":
print("🏷️ Initialisation des tags prédéfinis...\n")
seed_tags()
print("\n✨ Terminé!")

View File

@@ -0,0 +1 @@
"""Services for business logic."""

View File

@@ -0,0 +1,165 @@
"""Service de prédiction de tags pour les dépenses basé sur l'historique."""
from typing import Optional
from collections import Counter
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from ..database.models import Depense, Tag
class TagPrediction:
"""Représente une prédiction de tag."""
def __init__(
self,
tag_id: Optional[int],
tag_name: Optional[str],
confidence: float,
reason: str,
):
self.tag_id = tag_id
self.tag_name = tag_name
self.confidence = confidence
self.reason = reason
def to_dict(self) -> dict:
"""Convertit en dictionnaire."""
return {
"tag_id": self.tag_id,
"tag_name": self.tag_name,
"confidence": self.confidence,
"reason": self.reason,
}
class TagPredictor:
"""Service de prédiction de tags basé sur l'historique."""
def __init__(self, session: Session):
self.session = session
def predict_for_depense(self, depense_data: dict) -> TagPrediction:
"""Prédit le tag pour une dépense donnée.
Args:
depense_data: Dict contenant 'fournisseur', 'sous_categorie', 'description'
Returns:
TagPrediction avec le tag suggéré et la confiance
"""
fournisseur = (depense_data.get("fournisseur") or "").strip()
sous_categorie = (depense_data.get("sous_categorie") or "").strip()
# Stratégie 1: Recherche par fournisseur exact
if fournisseur:
prediction = self._predict_by_fournisseur(fournisseur)
if prediction:
return prediction
# Stratégie 2: Recherche par sous-catégorie
if sous_categorie:
prediction = self._predict_by_sous_categorie(sous_categorie)
if prediction:
return prediction
# Aucune prédiction trouvée
return TagPrediction(
tag_id=None,
tag_name=None,
confidence=0.0,
reason="Aucun historique trouvé",
)
def _predict_by_fournisseur(self, fournisseur: str) -> Optional[TagPrediction]:
"""Prédit le tag basé sur le fournisseur.
Retourne le tag le plus fréquemment utilisé pour ce fournisseur.
"""
# Requête pour trouver toutes les dépenses avec ce fournisseur et un tag
stmt = (
select(Depense.tag_id, Tag.nom, func.count(Depense.id))
.join(Tag, Depense.tag_id == Tag.id)
.where(
Depense.fournisseur.ilike(f"%{fournisseur}%"),
Depense.tag_id.is_not(None),
)
.group_by(Depense.tag_id, Tag.nom)
.order_by(func.count(Depense.id).desc())
)
result = self.session.execute(stmt).first()
if result:
tag_id, tag_name, count = result
total_stmt = select(func.count(Depense.id)).where(
Depense.fournisseur.ilike(f"%{fournisseur}%")
)
total = self.session.execute(total_stmt).scalar() or 0
confidence = (count / total * 100) if total > 0 else 0
return TagPrediction(
tag_id=tag_id,
tag_name=tag_name,
confidence=round(confidence, 1),
reason=f"Basé sur {count} occurrence(s) pour ce fournisseur",
)
return None
def _predict_by_sous_categorie(
self, sous_categorie: str
) -> Optional[TagPrediction]:
"""Prédit le tag basé sur la sous-catégorie.
Retourne le tag le plus fréquemment utilisé pour cette sous-catégorie.
"""
# Requête pour trouver toutes les dépenses avec cette sous-catégorie et un tag
stmt = (
select(Depense.tag_id, Tag.nom, func.count(Depense.id))
.join(Tag, Depense.tag_id == Tag.id)
.where(
Depense.sous_categorie.ilike(f"%{sous_categorie}%"),
Depense.tag_id.is_not(None),
)
.group_by(Depense.tag_id, Tag.nom)
.order_by(func.count(Depense.id).desc())
)
result = self.session.execute(stmt).first()
if result:
tag_id, tag_name, count = result
total_stmt = select(func.count(Depense.id)).where(
Depense.sous_categorie.ilike(f"%{sous_categorie}%")
)
total = self.session.execute(total_stmt).scalar() or 0
confidence = (count / total * 100) if total > 0 else 0
return TagPrediction(
tag_id=tag_id,
tag_name=tag_name,
confidence=round(confidence, 1),
reason=f"Basé sur {count} occurrence(s) pour cette sous-catégorie",
)
return None
def predict_batch(self, depenses_data: list[dict]) -> list[TagPrediction]:
"""Prédit les tags pour une liste de dépenses.
Args:
depenses_data: Liste de dicts contenant les infos des dépenses
Returns:
Liste de TagPrediction dans le même ordre
"""
predictions = []
for depense in depenses_data:
prediction = self.predict_for_depense(depense)
predictions.append(prediction)
return predictions