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>
<!-- Right panel: JSON Viewer -->
<!-- Right panel: JSON Viewer or Tagging Step -->
<div class="w-1/2 flex flex-col bg-gray-50">
<!-- 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
@click="extractCurrentFile"
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>
</div>
<!-- Save button bar (when data is extracted) -->
<div v-if="extractedData && !isExtracting" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<div class="flex items-center gap-3">
<button
@click="saveToDatabase"
:disabled="isSaving"
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"
>
<span v-if="isSaving">Sauvegarde en cours...</span>
<span v-else>Sauvegarder en base</span>
</button>
<!-- Continue to tagging button (when data is extracted) -->
<div v-if="extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<!-- Duplicate warning -->
<div v-if="saveMessage && saveMessage.type === 'duplicate'" class="mb-3">
<div class="px-4 py-3 rounded-lg text-sm bg-yellow-100 text-yellow-800 border border-yellow-200">
<div class="font-medium flex items-center gap-2">
<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" />
</svg>
{{ saveMessage.title }}
</div>
<div v-if="saveMessage.details" class="mt-1 text-xs opacity-90">{{ saveMessage.details }}</div>
</div>
</div>
<!-- Save feedback message -->
<div v-if="saveMessage" class="mt-3">
<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>
<button
@click="goToTagging"
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Continuer vers le tagging
</button>
</div>
<!-- Tagging step -->
<TaggingStep
v-if="showTagging && extractedData"
:depenses="extractedData.data.recapitulatif_operations"
: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>
<!-- 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>
</div>
<!-- JSON Viewer (only shown when not tagging) -->
<JsonViewer
v-else
v-if="pdfFile && !showTagging"
:data="extractedData"
:is-loading="isExtracting"
class="flex-1 overflow-hidden"
@@ -100,6 +120,7 @@ import { useRouter } from 'vue-router'
import { pendingFile } from '../store'
import PdfPreview from '../components/PdfPreview.vue'
import JsonViewer from '../components/JsonViewer.vue'
import TaggingStep from '../components/TaggingStep.vue'
const router = useRouter()
@@ -110,6 +131,8 @@ const extractedData = ref(null)
const isExtracting = ref(false)
const isSaving = ref(false)
const saveMessage = ref(null)
const showTagging = ref(false)
const isDuplicate = ref(false)
// Check for pending file from store on mount
onMounted(() => {
@@ -124,12 +147,18 @@ function triggerFileInput() {
fileInput.value?.click()
}
function resetState() {
extractedData.value = null
saveMessage.value = null
showTagging.value = false
isDuplicate.value = false
}
function onFileChange(e) {
const file = e.target.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) {
pdfFile.value = file
extractedData.value = null
saveMessage.value = null
resetState()
extractCurrentFile()
}
e.target.value = ''
@@ -140,8 +169,7 @@ function onDrop(e) {
const file = e.dataTransfer.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) {
pdfFile.value = file
extractedData.value = null
saveMessage.value = null
resetState()
extractCurrentFile()
}
}
@@ -150,6 +178,7 @@ async function extractCurrentFile() {
if (!pdfFile.value) return
isExtracting.value = true
saveMessage.value = null
try {
const formData = new FormData()
@@ -167,6 +196,9 @@ async function extractCurrentFile() {
const data = await response.json()
extractedData.value = data
// Check for duplicate immediately after extraction
await checkDuplicate()
} catch (err) {
console.error('Extraction error:', err)
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
isSaving.value = true
@@ -189,7 +256,9 @@ async function saveToDatabase() {
},
body: JSON.stringify({
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 ..database import init_db, get_session, DatabaseService
from ..database.service import DuplicateDocumentError
from ..services.tag_predictor import TagPredictor
app = FastAPI(
title="Plesna Gérance API",
@@ -38,6 +39,14 @@ class SaveRequest(BaseModel):
source_file: str | None = None
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):
@@ -159,6 +168,8 @@ async def save_document(
- **source_file**: Nom du fichier PDF original (optionnel)
- **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,
ou une erreur si le document existe deja (doublon).
@@ -166,7 +177,10 @@ async def save_document(
try:
db_service = DatabaseService(session)
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(
@@ -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)
if FRONTEND_DIST.exists():
# Serve static assets

View File

@@ -24,6 +24,22 @@ class Base(DeclarativeBase):
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):
"""Table des immeubles gérés."""
@@ -201,8 +217,11 @@ class Depense(Base):
lot_id = Column(
Integer, ForeignKey("lots.id"), nullable=True
) # 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.
sous_categorie = Column(String(255), nullable=True) # Nettoyage immeuble, etc.
fournisseur = Column(String(255), nullable=True)
@@ -221,12 +240,14 @@ class Depense(Base):
Index("ix_depense_document", "document_id"),
Index("ix_depense_immeuble", "immeuble_id"),
Index("ix_depense_categorie", "categorie"),
Index("ix_depense_tag", "tag_id"),
)
# Relations
document = relationship("Document", back_populates="depenses")
immeuble = relationship("Immeuble", back_populates="depenses")
lot = relationship("Lot", back_populates="depenses")
tag = relationship("Tag", back_populates="depenses")
def __repr__(self) -> str:
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.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):
@@ -94,19 +94,27 @@ class DatabaseService:
except (ValueError, TypeError):
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.
Args:
data: The 'data' portion of the extracted JSON (contains metadata,
situation_locataires, recapitulatif_operations)
source_file: Original PDF filename
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
DuplicateDocumentError: If document already exists and overwrite=False
"""
metadata = data.get("metadata", {})
doc_info = metadata.get("document", {})
@@ -124,7 +132,12 @@ class DatabaseService:
# Check for duplicates
existing = self.check_duplicate(reference, doc_date)
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
immeuble = self.get_or_create_immeuble(
@@ -156,8 +169,15 @@ class DatabaseService:
self._save_situation_locataire(document, immeuble, situation)
# Process recapitulatif_operations (depenses)
for operation in data.get("recapitulatif_operations", []):
self._save_operation(document, immeuble, operation)
# 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
@@ -205,7 +225,11 @@ class DatabaseService:
self.session.add(revenu)
def _save_operation(
self, document: Document, immeuble: Immeuble, operation: dict
self,
document: Document,
immeuble: Immeuble,
operation: dict,
tag_id: int = None,
) -> None:
"""Save operation (depense)."""
montants = operation.get("montants", {})
@@ -221,6 +245,7 @@ class DatabaseService:
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"),
@@ -312,3 +337,13 @@ class DatabaseService:
"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)

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