feat: add analytics

This commit is contained in:
2026-01-19 21:40:10 +01:00
parent 0fd6bdaeb3
commit aac4d194e3
19 changed files with 1940 additions and 347 deletions

View File

@@ -8,8 +8,10 @@
"name": "plesna-gerance-frontend",
"version": "0.1.0",
"dependencies": {
"chart.js": "^4.4.1",
"pdfjs-dist": "^4.0.379",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
@@ -508,6 +510,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz",
@@ -1439,6 +1447,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2562,6 +2582,16 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",

View File

@@ -9,8 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"chart.js": "^4.4.1",
"pdfjs-dist": "^4.0.379",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.0",
"vue-router": "^4.6.4"
},
"devDependencies": {

View File

@@ -18,6 +18,14 @@
Accueil
</router-link>
<router-link
to="/analytics"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/analytics' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
Analyses
</router-link>
<!-- Import button with file input -->
<label
class="px-3 py-1.5 text-sm rounded transition-colors cursor-pointer bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white"

View File

@@ -0,0 +1,92 @@
<template>
<div class="bg-gray-900 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-300 mb-4">Repartition par categorie</h3>
<div class="h-64">
<Doughnut
v-if="chartData.labels.length > 0"
:data="chartData"
:options="chartOptions"
/>
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
Aucune donnee
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Doughnut } from 'vue-chartjs'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
ChartJS.register(ArcElement, Tooltip, Legend)
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
const colors = [
'#3B82F6', // blue
'#EF4444', // red
'#10B981', // green
'#F59E0B', // amber
'#8B5CF6', // purple
'#EC4899', // pink
'#06B6D4', // cyan
'#84CC16', // lime
'#F97316', // orange
'#6366F1', // indigo
]
const chartData = computed(() => {
const items = props.data.slice(0, 10) // Top 10 categories
return {
labels: items.map(d => formatCategorie(d.categorie)),
datasets: [{
data: items.map(d => d.total_debit),
backgroundColor: colors,
borderColor: '#1F2937',
borderWidth: 2
}]
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
color: '#9CA3AF',
font: { size: 11 },
boxWidth: 12,
padding: 8
}
},
tooltip: {
callbacks: {
label: (ctx) => {
const value = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(ctx.raw)
return ` ${value}`
}
}
}
}
}
function formatCategorie(cat) {
if (!cat) return 'Non categorise'
return cat
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^\w/, c => c.toUpperCase())
.substring(0, 25)
}
</script>

View File

@@ -0,0 +1,217 @@
<template>
<div class="bg-gray-900 rounded-lg overflow-hidden">
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-300">
Detail des depenses
<span class="text-gray-500 font-normal ml-2">({{ depenses.length }} resultats)</span>
</h3>
<button
v-if="depenses.length > 0"
@click="exportCsv"
class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Exporter CSV
</button>
</div>
<div v-if="isLoading" class="p-8 text-center text-gray-500">
Chargement...
</div>
<div v-else-if="depenses.length === 0" class="p-8 text-center text-gray-500">
Aucune depense trouvee avec les filtres actuels.
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-800/50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Date</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Immeuble</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Lot</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Categorie</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Fournisseur</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Description</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Tag</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-400 uppercase">Debit</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-400 uppercase">Credit</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800">
<tr
v-for="dep in paginatedDepenses"
:key="dep.id"
class="hover:bg-gray-800/50"
>
<td class="px-3 py-2 text-gray-300 whitespace-nowrap">
{{ formatDate(dep.document_date) }}
</td>
<td class="px-3 py-2 text-gray-400 whitespace-nowrap">
{{ dep.immeuble_code }}
</td>
<td class="px-3 py-2 text-gray-400">
{{ dep.lot_numero || '-' }}
</td>
<td class="px-3 py-2 text-gray-300">
<div class="text-xs text-gray-500">{{ formatCategorie(dep.categorie) }}</div>
<div v-if="dep.sous_categorie" class="text-xs text-gray-400 truncate max-w-[150px]">
{{ dep.sous_categorie }}
</div>
</td>
<td class="px-3 py-2 text-white truncate max-w-[150px]">
{{ dep.fournisseur || '-' }}
</td>
<td class="px-3 py-2 text-gray-400 truncate max-w-[200px]">
{{ dep.description || '-' }}
</td>
<td class="px-3 py-2">
<span
v-if="dep.tag_nom"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-500/20 text-blue-400"
>
{{ dep.tag_nom }}
</span>
<span v-else class="text-gray-600">-</span>
</td>
<td class="px-3 py-2 text-right text-red-400 font-mono whitespace-nowrap">
{{ dep.debit > 0 ? formatCurrency(dep.debit) : '-' }}
</td>
<td class="px-3 py-2 text-right text-green-400 font-mono whitespace-nowrap">
{{ dep.credit > 0 ? formatCurrency(dep.credit) : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="px-4 py-3 border-t border-gray-700 flex items-center justify-between">
<div class="text-xs text-gray-500">
Affichage {{ startIndex + 1 }}-{{ Math.min(endIndex, depenses.length) }} sur {{ depenses.length }}
</div>
<div class="flex items-center gap-1">
<button
@click="currentPage = 1"
:disabled="currentPage === 1"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
&lt;&lt;
</button>
<button
@click="currentPage--"
:disabled="currentPage === 1"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
&lt;
</button>
<span class="px-3 text-xs text-gray-400">
Page {{ currentPage }} / {{ totalPages }}
</span>
<button
@click="currentPage++"
:disabled="currentPage === totalPages"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
&gt;
</button>
<button
@click="currentPage = totalPages"
:disabled="currentPage === totalPages"
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
&gt;&gt;
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
depenses: {
type: Array,
default: () => []
},
isLoading: {
type: Boolean,
default: false
}
})
const pageSize = 25
const currentPage = ref(1)
// Reset page when depenses change
watch(() => props.depenses, () => {
currentPage.value = 1
})
const totalPages = computed(() => Math.ceil(props.depenses.length / pageSize))
const startIndex = computed(() => (currentPage.value - 1) * pageSize)
const endIndex = computed(() => startIndex.value + pageSize)
const paginatedDepenses = computed(() =>
props.depenses.slice(startIndex.value, endIndex.value)
)
function formatDate(dateStr) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleDateString('fr-FR')
}
function formatCategorie(cat) {
if (!cat) return '-'
return cat
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^\w/, c => c.toUpperCase())
}
function formatCurrency(value) {
if (value == null) return '-'
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(value)
}
function exportCsv() {
const headers = [
'Date', 'Reference', 'Immeuble', 'Lot', 'Categorie', 'Sous-categorie',
'Fournisseur', 'Description', 'Tag', 'Debit', 'Credit', 'TVA', 'Locatif', 'Deductible'
]
const rows = props.depenses.map(d => [
d.document_date,
d.document_reference,
d.immeuble_code,
d.lot_numero || '',
d.categorie || '',
d.sous_categorie || '',
d.fournisseur || '',
d.description || '',
d.tag_nom || '',
d.debit,
d.credit,
d.tva,
d.locatif,
d.deductible
])
const csvContent = [
headers.join(';'),
...rows.map(r => r.map(v => `"${v}"`).join(';'))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `depenses_${new Date().toISOString().split('T')[0]}.csv`
link.click()
}
</script>

View File

@@ -0,0 +1,208 @@
<template>
<div class="bg-gray-900 rounded-lg p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-300">Filtres</h3>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="text-xs text-blue-400 hover:text-blue-300"
>
Reinitialiser
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<!-- Immeuble -->
<div>
<label class="block text-xs text-gray-400 mb-1">Immeuble</label>
<select
v-model="filters.immeuble_id"
@change="onImmeubleChange"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
>
<option :value="null">Tous</option>
<option v-for="imm in immeubles" :key="imm.id" :value="imm.id">
{{ imm.code }} - {{ imm.adresse || 'N/A' }}
</option>
</select>
</div>
<!-- Lot -->
<div>
<label class="block text-xs text-gray-400 mb-1">Lot</label>
<select
v-model="filters.lot_id"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
:disabled="!filters.immeuble_id"
>
<option :value="null">Tous</option>
<option v-for="lot in filteredLots" :key="lot.id" :value="lot.id">
{{ lot.numero }} {{ lot.type ? `(${lot.type})` : '' }}
</option>
</select>
</div>
<!-- Categorie -->
<div>
<label class="block text-xs text-gray-400 mb-1">Categorie</label>
<select
v-model="filters.categorie"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
>
<option :value="null">Toutes</option>
<option v-for="cat in categories" :key="cat" :value="cat">
{{ formatCategorie(cat) }}
</option>
</select>
</div>
<!-- Tag -->
<div>
<label class="block text-xs text-gray-400 mb-1">Tag</label>
<select
v-model="filters.tag_id"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
>
<option :value="null">Tous</option>
<option v-for="tag in tags" :key="tag.id" :value="tag.id">
{{ tag.nom }}
</option>
</select>
</div>
<!-- Date debut -->
<div>
<label class="block text-xs text-gray-400 mb-1">Date debut</label>
<input
type="date"
v-model="filters.date_debut"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
<!-- Date fin -->
<div>
<label class="block text-xs text-gray-400 mb-1">Date fin</label>
<input
type="date"
v-model="filters.date_fin"
@change="emitFilters"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
</div>
<!-- Fournisseur (recherche) -->
<div class="max-w-xs">
<label class="block text-xs text-gray-400 mb-1">Fournisseur</label>
<input
type="text"
v-model="filters.fournisseur"
@input="emitFilters"
placeholder="Rechercher..."
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
const emit = defineEmits(['filter-change'])
// Reference data
const immeubles = ref([])
const lots = ref([])
const categories = ref([])
const tags = ref([])
// Local filters state
const filters = reactive({
immeuble_id: null,
lot_id: null,
categorie: null,
tag_id: null,
fournisseur: null,
date_debut: null,
date_fin: null
})
const filteredLots = computed(() => {
if (!filters.immeuble_id) return []
return lots.value.filter(l => l.immeuble_id === filters.immeuble_id)
})
const hasActiveFilters = computed(() => {
return filters.immeuble_id !== null ||
filters.lot_id !== null ||
filters.categorie !== null ||
filters.tag_id !== null ||
filters.date_debut !== null ||
filters.date_fin !== null ||
(filters.fournisseur && filters.fournisseur.length > 0)
})
function onImmeubleChange() {
// Reset lot when immeuble changes
filters.lot_id = null
emitFilters()
}
function emitFilters() {
console.log('Emitting filters:', { ...filters })
emit('filter-change', { ...filters })
}
function resetFilters() {
filters.immeuble_id = null
filters.lot_id = null
filters.categorie = null
filters.tag_id = null
filters.fournisseur = null
filters.date_debut = null
filters.date_fin = null
emitFilters()
}
function formatCategorie(cat) {
if (!cat) return '-'
return cat
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^\w/, c => c.toUpperCase())
}
async function loadReferenceData() {
try {
const [immeublesRes, lotsRes, categoriesRes, tagsRes] = await Promise.all([
fetch('/api/immeubles'),
fetch('/api/lots'),
fetch('/api/analytics/categories'),
fetch('/api/tags')
])
if (immeublesRes.ok) immeubles.value = await immeublesRes.json()
if (lotsRes.ok) lots.value = await lotsRes.json()
if (categoriesRes.ok) categories.value = await categoriesRes.json()
if (tagsRes.ok) tags.value = await tagsRes.json()
console.log('Reference data loaded:', {
immeubles: immeubles.value.length,
lots: lots.value.length,
categories: categories.value.length,
tags: tags.value.length
})
} catch (err) {
console.error('Failed to load reference data:', err)
}
}
onMounted(() => {
loadReferenceData()
})
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<div
v-for="kpi in kpis"
:key="kpi.key"
class="bg-gray-900 rounded-lg p-4"
>
<div class="text-xs text-gray-400 mb-1">{{ kpi.label }}</div>
<div class="text-xl font-bold" :class="kpi.colorClass || 'text-white'">
{{ formatValue(kpi.key, summary[kpi.key]) }}
</div>
<div v-if="kpi.subLabel" class="text-xs text-gray-500 mt-1">
{{ kpi.subLabel }}
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
summary: {
type: Object,
required: true
}
})
const kpis = [
{ key: 'total_count', label: 'Operations', colorClass: 'text-white' },
{ key: 'total_debit', label: 'Total Debit', colorClass: 'text-red-400' },
{ key: 'total_credit', label: 'Total Credit', colorClass: 'text-green-400' },
{ key: 'total_tva', label: 'Total TVA', colorClass: 'text-blue-400' },
{ key: 'total_locatif', label: 'Part Locative', colorClass: 'text-amber-400' },
{ key: 'total_deductible', label: 'Deductible', colorClass: 'text-purple-400' }
]
function formatValue(key, value) {
if (value === undefined || value === null) return '-'
if (key === 'total_count') {
return value.toLocaleString('fr-FR')
}
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
}).format(value)
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div class="bg-gray-900 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-300 mb-4">Evolution mensuelle</h3>
<div class="h-64">
<Bar
v-if="chartData.labels.length > 0"
:data="chartData"
:options="chartOptions"
/>
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
Aucune donnee
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
const monthNames = [
'Jan', 'Fev', 'Mar', 'Avr', 'Mai', 'Juin',
'Juil', 'Aout', 'Sep', 'Oct', 'Nov', 'Dec'
]
const chartData = computed(() => {
const items = props.data.slice(-24) // Last 24 months
return {
labels: items.map(d => `${monthNames[d.month - 1]} ${d.year}`),
datasets: [
{
label: 'Debit',
data: items.map(d => d.total_debit),
backgroundColor: '#EF4444',
borderRadius: 4
},
{
label: 'Credit',
data: items.map(d => d.total_credit),
backgroundColor: '#10B981',
borderRadius: 4
}
]
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
color: '#9CA3AF',
font: { size: 11 },
boxWidth: 12,
padding: 15
}
},
tooltip: {
callbacks: {
label: (ctx) => {
const value = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(ctx.raw)
return ` ${ctx.dataset.label}: ${value}`
}
}
}
},
scales: {
x: {
ticks: { color: '#6B7280', font: { size: 10 } },
grid: { color: '#374151' }
},
y: {
ticks: {
color: '#6B7280',
callback: (value) => {
if (value >= 1000) return `${(value / 1000).toFixed(0)}k`
return value
}
},
grid: { color: '#374151' }
}
}
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div class="bg-gray-900 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-300 mb-4">Top fournisseurs</h3>
<div class="h-64">
<Bar
v-if="chartData.labels.length > 0"
:data="chartData"
:options="chartOptions"
/>
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
Aucune donnee
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Tooltip
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
const chartData = computed(() => {
const items = props.data.slice(0, 10) // Top 10
return {
labels: items.map(d => truncate(d.fournisseur || 'Non specifie', 20)),
datasets: [{
data: items.map(d => d.total_debit),
backgroundColor: '#3B82F6',
borderRadius: 4
}]
}
})
const chartOptions = {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const value = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(ctx.raw)
return ` ${value}`
}
}
}
},
scales: {
x: {
ticks: {
color: '#6B7280',
callback: (value) => {
if (value >= 1000) return `${(value / 1000).toFixed(0)}k`
return value
}
},
grid: { color: '#374151' }
},
y: {
ticks: { color: '#9CA3AF', font: { size: 11 } },
grid: { display: false }
}
}
}
function truncate(str, len) {
if (!str) return ''
return str.length > len ? str.substring(0, len) + '...' : str
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div class="flex-1 overflow-auto p-6 bg-gray-800">
<div class="max-w-7xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-white">Analyse des depenses</h1>
<p class="text-sm text-gray-400 mt-1">
Visualisez et filtrez les depenses de vos immeubles
</p>
</div>
<div v-if="lastUpdate" class="text-xs text-gray-500">
Derniere mise a jour: {{ lastUpdate }}
</div>
</div>
<!-- Filters -->
<FilterPanel @filter-change="onFiltersChange" />
<!-- KPI Cards -->
<KpiCards :summary="summary" />
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CategoryChart :data="summary.by_category || []" />
<MonthlyChart :data="summary.by_month || []" />
</div>
<!-- Top Fournisseurs -->
<TopFournisseursChart :data="summary.by_fournisseur || []" />
<!-- Depenses Table -->
<DepensesTable
:depenses="depenses"
:is-loading="isLoadingDepenses"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import FilterPanel from '../components/analytics/FilterPanel.vue'
import KpiCards from '../components/analytics/KpiCards.vue'
import CategoryChart from '../components/analytics/CategoryChart.vue'
import MonthlyChart from '../components/analytics/MonthlyChart.vue'
import TopFournisseursChart from '../components/analytics/TopFournisseursChart.vue'
import DepensesTable from '../components/analytics/DepensesTable.vue'
// Current filters state
let currentFilters = {
immeuble_id: null,
lot_id: null,
categorie: null,
tag_id: null,
fournisseur: null,
date_debut: null,
date_fin: null
}
const summary = ref({
total_count: 0,
total_debit: 0,
total_credit: 0,
total_tva: 0,
total_locatif: 0,
total_deductible: 0,
by_category: [],
by_tag: [],
by_month: [],
by_fournisseur: []
})
const depenses = ref([])
const isLoadingDepenses = ref(false)
const lastUpdate = ref(null)
// Debounce timer
let debounceTimer = null
function onFiltersChange(newFilters) {
currentFilters = { ...newFilters }
// Debounce
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
loadData()
}, 300)
}
function buildQueryParams() {
const params = new URLSearchParams()
if (currentFilters.immeuble_id !== null) params.append('immeuble_id', currentFilters.immeuble_id)
if (currentFilters.lot_id !== null) params.append('lot_id', currentFilters.lot_id)
if (currentFilters.categorie !== null) params.append('categorie', currentFilters.categorie)
if (currentFilters.tag_id !== null) params.append('tag_id', currentFilters.tag_id)
if (currentFilters.fournisseur) params.append('fournisseur', currentFilters.fournisseur)
if (currentFilters.date_debut) params.append('date_debut', currentFilters.date_debut)
if (currentFilters.date_fin) params.append('date_fin', currentFilters.date_fin)
return params.toString()
}
async function loadData() {
const queryString = buildQueryParams()
const summaryUrl = queryString ? `/api/analytics/depenses/summary?${queryString}` : '/api/analytics/depenses/summary'
const depensesUrl = queryString ? `/api/analytics/depenses?${queryString}&limit=500` : '/api/analytics/depenses?limit=500'
console.log('Loading data with filters:', currentFilters)
console.log('Summary URL:', summaryUrl)
isLoadingDepenses.value = true
try {
const [summaryRes, depensesRes] = await Promise.all([
fetch(summaryUrl),
fetch(depensesUrl)
])
if (summaryRes.ok) {
summary.value = await summaryRes.json()
console.log('Summary loaded:', summary.value.total_count, 'depenses')
}
if (depensesRes.ok) {
depenses.value = await depensesRes.json()
}
lastUpdate.value = new Date().toLocaleTimeString('fr-FR')
} catch (err) {
console.error('Failed to load analytics data:', err)
} finally {
isLoadingDepenses.value = false
}
}
onMounted(() => {
loadData()
})
</script>

View File

@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomePage from './pages/HomePage.vue'
import ExtractPage from './pages/ExtractPage.vue'
import AnalyticsPage from './pages/AnalyticsPage.vue'
const routes = [
{
@@ -12,6 +13,11 @@ const routes = [
path: '/extract',
name: 'extract',
component: ExtractPage
},
{
path: '/analytics',
name: 'analytics',
component: AnalyticsPage
}
]

View File

@@ -1,20 +1,14 @@
"""Application FastAPI pour l'extraction de comptes rendus de gérance."""
import tempfile
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile, Depends
from fastapi.responses import JSONResponse, FileResponse
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sqlalchemy.orm import Session
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
from ..database import init_db
from .routes import extraction_router, documents_router, tags_router, analytics_router
app = FastAPI(
title="Plesna Gérance API",
@@ -33,51 +27,14 @@ async def startup_event():
init_db()
# Pydantic models for API
class SaveRequest(BaseModel):
"""Request body for saving extracted data."""
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):
"""Response for save operation."""
success: bool
message: str
document_id: int | None = None
reference: str | None = None
date: str | None = None
class DocumentSummary(BaseModel):
"""Summary of a saved document."""
id: int
reference: str
date: str
type: str | None
source_file: str | None
immeuble_code: str | None
immeuble_adresse: str | None
solde_montant: float | None
solde_type: str | None
created_at: str
# Determine the frontend dist path
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
# Include routers
app.include_router(extraction_router)
app.include_router(documents_router)
app.include_router(tags_router)
app.include_router(analytics_router)
# Health check endpoints (keep in main app)
@app.get("/api", tags=["health"])
async def api_root() -> dict:
"""Endpoint racine de l'API - informations."""
@@ -94,300 +51,8 @@ async def health() -> dict:
return {"status": "ok"}
@app.post("/api/extract", tags=["extraction"])
async def extract_pdf(
file: UploadFile = File(..., description="Fichier PDF de compte rendu de gérance"),
) -> JSONResponse:
"""Extrait les données d'un PDF de compte rendu de gérance.
Upload un fichier PDF et retourne les données structurées en JSON.
- **file**: Fichier PDF à analyser (Content-Type: multipart/form-data)
Retourne un objet JSON contenant:
- **source_file**: Nom du fichier uploadé
- **data**: Données extraites (metadata, situation_locataires, recapitulatif_operations)
"""
# Validation du type de fichier
if not file.filename:
raise HTTPException(status_code=400, detail="Nom de fichier manquant")
if not file.filename.lower().endswith(".pdf"):
raise HTTPException(
status_code=400,
detail=f"Le fichier doit être un PDF. Reçu: {file.filename}",
)
# Validation du content-type (si fourni)
if file.content_type and file.content_type not in (
"application/pdf",
"application/octet-stream",
):
raise HTTPException(
status_code=400,
detail=f"Content-Type invalide. Attendu: application/pdf, reçu: {file.content_type}",
)
tmp_path: Path | None = None
# Sauvegarde temporaire du fichier uploadé
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
content = await file.read()
tmp_file.write(content)
tmp_path = Path(tmp_file.name)
# Extraction des données
try:
data = extract_compte_rendu(str(tmp_path))
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Erreur lors de l'extraction du PDF: {str(e)}",
)
return JSONResponse(
content={
"source_file": file.filename,
"data": data,
}
)
finally:
# Nettoyage du fichier temporaire
if tmp_path and tmp_path.exists():
tmp_path.unlink()
@app.post("/api/save", tags=["database"], response_model=SaveResponse)
async def save_document(
request: SaveRequest,
session: Session = Depends(get_session),
) -> SaveResponse:
"""Sauvegarde les donnees extraites en base de donnees.
- **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).
"""
try:
db_service = DatabaseService(session)
document = db_service.save_document(
data=request.data,
source_file=request.source_file,
depenses_tags=request.depenses_tags,
overwrite=request.overwrite,
)
return SaveResponse(
success=True,
message="Document sauvegarde avec succes",
document_id=document.id,
reference=document.reference,
date=str(document.date),
)
except DuplicateDocumentError as e:
return SaveResponse(
success=False,
message=f"Document deja existant: reference={e.reference}, date={e.date}",
reference=e.reference,
date=str(e.date),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Erreur lors de la sauvegarde: {str(e)}"
)
@app.get("/api/stats", tags=["database"])
async def get_stats(
session: Session = Depends(get_session),
) -> dict:
"""Retourne les statistiques globales de la base de donnees.
Compteurs pour chaque table principale.
"""
from sqlalchemy import func, select
from ..database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
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,
}
@app.get("/api/documents", tags=["database"])
async def list_documents(
limit: int = 100,
offset: int = 0,
session: Session = Depends(get_session),
) -> list[DocumentSummary]:
"""Liste les documents importes en base.
- **limit**: Nombre maximum de documents a retourner (defaut: 100)
- **offset**: Decalage pour la pagination (defaut: 0)
Retourne une liste de documents avec leurs metadonnees principales.
"""
db_service = DatabaseService(session)
documents = db_service.list_documents(limit=limit, offset=offset)
return [
DocumentSummary(
id=doc.id,
reference=doc.reference,
date=str(doc.date),
type=doc.type,
source_file=doc.source_file,
immeuble_code=doc.immeuble.code if doc.immeuble else None,
immeuble_adresse=doc.immeuble.adresse if doc.immeuble else None,
solde_montant=doc.solde_montant,
solde_type=doc.solde_type,
created_at=doc.created_at.isoformat() if doc.created_at else None,
)
for doc in documents
]
@app.get("/api/documents/{document_id}", tags=["database"])
async def get_document(
document_id: int,
session: Session = Depends(get_session),
) -> dict:
"""Recupere un document par son ID avec toutes ses donnees.
Retourne le document complet incluant le JSON original.
"""
db_service = DatabaseService(session)
document = db_service.get_document_by_id(document_id)
if not document:
raise HTTPException(status_code=404, detail="Document non trouve")
import json
return {
"id": document.id,
"reference": document.reference,
"date": str(document.date),
"type": document.type,
"source_file": document.source_file,
"immeuble": {
"code": document.immeuble.code,
"adresse": document.immeuble.adresse,
"ville": document.immeuble.ville,
"code_postal": document.immeuble.code_postal,
}
if document.immeuble
else None,
"solde": {
"montant": document.solde_montant,
"type": document.solde_type,
"date_arrete": str(document.solde_date_arrete)
if document.solde_date_arrete
else None,
},
"editeur": {
"nom": document.editeur_nom,
"siret": document.editeur_siret,
},
"json_data": json.loads(document.json_data) if document.json_data else None,
"created_at": document.created_at.isoformat() if document.created_at else None,
}
@app.get("/api/check-duplicate", tags=["database"])
async def check_duplicate(
reference: str,
date: str,
session: Session = Depends(get_session),
) -> dict:
"""Verifie si un document existe deja en base.
- **reference**: Reference du document
- **date**: Date du document (format YYYY-MM-DD)
Retourne {exists: true/false, document_id: ...}
"""
from datetime import datetime
try:
doc_date = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(
status_code=400, detail="Format de date invalide. Utiliser YYYY-MM-DD"
)
db_service = DatabaseService(session)
existing = db_service.check_duplicate(reference, doc_date)
return {
"exists": existing is not None,
"document_id": existing.id if existing else None,
"reference": reference,
"date": date,
}
@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)}"
)
# Determine the frontend dist path
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
# Mount static files for production (if dist exists)

View File

@@ -0,0 +1,13 @@
"""API Routes package."""
from .extraction import router as extraction_router
from .documents import router as documents_router
from .tags import router as tags_router
from .analytics import router as analytics_router
__all__ = [
"extraction_router",
"documents_router",
"tags_router",
"analytics_router",
]

View File

@@ -0,0 +1,438 @@
"""Analytics routes - Data analysis and reporting endpoints."""
from datetime import date
from collections import defaultdict
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import select, func, distinct, extract
from ...database import get_session
from ...database.models import Depense, Document, Immeuble, Lot, Tag
from ..schemas.models import (
ImmeubleResponse,
LotResponse,
DepenseDetail,
DepensesSummary,
CategorySummary,
TagSummary,
MonthlySummary,
FournisseurSummary,
FournisseurResponse,
TagResponse,
)
router = APIRouter(prefix="/api", tags=["analytics"])
# ============================================================
# Reference data endpoints (for filters)
# ============================================================
@router.get("/immeubles", response_model=list[ImmeubleResponse])
async def list_immeubles(
session: Session = Depends(get_session),
) -> list[ImmeubleResponse]:
"""Liste tous les immeubles avec statistiques.
Retourne la liste des immeubles avec le nombre de lots et de depenses.
"""
# Get immeubles with counts
stmt = (
select(
Immeuble,
func.count(distinct(Lot.id)).label("nb_lots"),
func.count(distinct(Depense.id)).label("nb_depenses"),
)
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
.outerjoin(Depense, Depense.immeuble_id == Immeuble.id)
.group_by(Immeuble.id)
.order_by(Immeuble.code)
)
result = session.execute(stmt)
rows = result.all()
return [
ImmeubleResponse(
id=row.Immeuble.id,
code=row.Immeuble.code,
adresse=row.Immeuble.adresse,
ville=row.Immeuble.ville,
code_postal=row.Immeuble.code_postal,
nb_lots=row.nb_lots or 0,
nb_depenses=row.nb_depenses or 0,
)
for row in rows
]
@router.get("/lots", response_model=list[LotResponse])
async def list_lots(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
session: Session = Depends(get_session),
) -> list[LotResponse]:
"""Liste tous les lots, optionnellement filtres par immeuble.
- **immeuble_id**: ID de l'immeuble pour filtrer (optionnel)
"""
stmt = (
select(Lot, Immeuble.code.label("immeuble_code"))
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.order_by(Immeuble.code, Lot.numero)
)
if immeuble_id is not None:
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
result = session.execute(stmt)
rows = result.all()
return [
LotResponse(
id=row.Lot.id,
numero=row.Lot.numero,
type=row.Lot.type,
immeuble_id=row.Lot.immeuble_id,
immeuble_code=row.immeuble_code,
)
for row in rows
]
@router.get("/fournisseurs", response_model=list[FournisseurResponse])
async def list_fournisseurs(
session: Session = Depends(get_session),
) -> list[FournisseurResponse]:
"""Liste tous les fournisseurs distincts avec statistiques.
Retourne la liste des fournisseurs avec le nombre de depenses et total.
"""
stmt = (
select(
Depense.fournisseur,
func.count(Depense.id).label("nb_depenses"),
func.sum(Depense.debit).label("total_debit"),
)
.where(Depense.fournisseur.isnot(None))
.where(Depense.fournisseur != "")
.group_by(Depense.fournisseur)
.order_by(func.sum(Depense.debit).desc())
)
result = session.execute(stmt)
rows = result.all()
return [
FournisseurResponse(
nom=row.fournisseur,
nb_depenses=row.nb_depenses or 0,
total_debit=row.total_debit or 0.0,
)
for row in rows
]
@router.get("/tags/stats", response_model=list[TagResponse])
async def list_tags_with_stats(
session: Session = Depends(get_session),
) -> list[TagResponse]:
"""Liste tous les tags avec statistiques.
Retourne la liste des tags avec le nombre de depenses associees.
"""
stmt = (
select(
Tag,
func.count(Depense.id).label("nb_depenses"),
)
.outerjoin(Depense, Depense.tag_id == Tag.id)
.group_by(Tag.id)
.order_by(Tag.nom)
)
result = session.execute(stmt)
rows = result.all()
return [
TagResponse(
id=row.Tag.id,
nom=row.Tag.nom,
nb_depenses=row.nb_depenses or 0,
)
for row in rows
]
# ============================================================
# Analytics endpoints
# ============================================================
@router.get("/analytics/depenses", response_model=list[DepenseDetail])
async def get_depenses(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
lot_id: int | None = Query(None, description="Filtrer par lot"),
tag_id: int | None = Query(None, description="Filtrer par tag"),
categorie: str | None = Query(None, description="Filtrer par categorie"),
fournisseur: str | None = Query(None, description="Filtrer par fournisseur"),
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
limit: int = Query(500, description="Nombre maximum de resultats"),
offset: int = Query(0, description="Decalage pour la pagination"),
session: Session = Depends(get_session),
) -> list[DepenseDetail]:
"""Retourne la liste des depenses filtrees avec details.
Filtres disponibles:
- **immeuble_id**: ID de l'immeuble
- **lot_id**: ID du lot
- **tag_id**: ID du tag
- **categorie**: Categorie de depense
- **fournisseur**: Nom du fournisseur
- **date_debut**: Date de debut (incluse)
- **date_fin**: Date de fin (incluse)
- **limit**: Nombre max de resultats (defaut: 500)
- **offset**: Decalage pour pagination
"""
stmt = (
select(
Depense,
Document.date.label("document_date"),
Document.reference.label("document_reference"),
Immeuble.code.label("immeuble_code"),
Immeuble.adresse.label("immeuble_adresse"),
Lot.numero.label("lot_numero"),
Tag.nom.label("tag_nom"),
)
.join(Document, Depense.document_id == Document.id)
.join(Immeuble, Depense.immeuble_id == Immeuble.id)
.outerjoin(Lot, Depense.lot_id == Lot.id)
.outerjoin(Tag, Depense.tag_id == Tag.id)
.order_by(Document.date.desc(), Depense.id.desc())
)
# Apply filters
if immeuble_id is not None:
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
if lot_id is not None:
stmt = stmt.where(Depense.lot_id == lot_id)
if tag_id is not None:
stmt = stmt.where(Depense.tag_id == tag_id)
if categorie is not None:
stmt = stmt.where(Depense.categorie == categorie)
if fournisseur is not None:
stmt = stmt.where(Depense.fournisseur.ilike(f"%{fournisseur}%"))
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)
stmt = stmt.limit(limit).offset(offset)
result = session.execute(stmt)
rows = result.all()
return [
DepenseDetail(
id=row.Depense.id,
document_id=row.Depense.document_id,
document_date=row.document_date,
document_reference=row.document_reference,
immeuble_id=row.Depense.immeuble_id,
immeuble_code=row.immeuble_code,
immeuble_adresse=row.immeuble_adresse,
lot_id=row.Depense.lot_id,
lot_numero=row.lot_numero,
tag_id=row.Depense.tag_id,
tag_nom=row.tag_nom,
categorie=row.Depense.categorie,
sous_categorie=row.Depense.sous_categorie,
fournisseur=row.Depense.fournisseur,
description=row.Depense.description,
debit=row.Depense.debit or 0.0,
credit=row.Depense.credit or 0.0,
tva=row.Depense.tva or 0.0,
locatif=row.Depense.locatif or 0.0,
deductible=row.Depense.deductible or 0.0,
)
for row in rows
]
@router.get("/analytics/depenses/summary", response_model=DepensesSummary)
async def get_depenses_summary(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
lot_id: int | None = Query(None, description="Filtrer par lot"),
tag_id: int | None = Query(None, description="Filtrer par tag"),
categorie: str | None = Query(None, description="Filtrer par categorie"),
fournisseur: str | None = Query(None, description="Filtrer par fournisseur"),
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
session: Session = Depends(get_session),
) -> DepensesSummary:
"""Retourne un resume agrege des depenses pour les graphiques.
Memes filtres que /analytics/depenses.
Retourne:
- Totaux globaux
- Repartition par categorie
- Repartition par tag
- Evolution mensuelle
- Top fournisseurs
"""
# Base query with filters
base_stmt = select(Depense).join(Document, Depense.document_id == Document.id)
if immeuble_id is not None:
base_stmt = base_stmt.where(Depense.immeuble_id == immeuble_id)
if lot_id is not None:
base_stmt = base_stmt.where(Depense.lot_id == lot_id)
if tag_id is not None:
base_stmt = base_stmt.where(Depense.tag_id == tag_id)
if categorie is not None:
base_stmt = base_stmt.where(Depense.categorie == categorie)
if fournisseur is not None:
base_stmt = base_stmt.where(Depense.fournisseur.ilike(f"%{fournisseur}%"))
if date_debut is not None:
base_stmt = base_stmt.where(Document.date >= date_debut)
if date_fin is not None:
base_stmt = base_stmt.where(Document.date <= date_fin)
# Get all matching depenses
result = session.execute(base_stmt)
depenses = result.scalars().all()
# Calculate totals
total_count = len(depenses)
total_debit = sum(d.debit or 0 for d in depenses)
total_credit = sum(d.credit or 0 for d in depenses)
total_tva = sum(d.tva or 0 for d in depenses)
total_locatif = sum(d.locatif or 0 for d in depenses)
total_deductible = sum(d.deductible or 0 for d in depenses)
# Get document dates for monthly aggregation
doc_dates = {}
for d in depenses:
if d.document_id not in doc_dates:
doc = session.get(Document, d.document_id)
doc_dates[d.document_id] = doc.date if doc else None
# Aggregate by category
by_category_dict = defaultdict(
lambda: {
"count": 0,
"debit": 0.0,
"credit": 0.0,
"tva": 0.0,
"locatif": 0.0,
"deductible": 0.0,
}
)
for d in depenses:
cat = d.categorie or "NON_CATEGORISE"
by_category_dict[cat]["count"] += 1
by_category_dict[cat]["debit"] += d.debit or 0
by_category_dict[cat]["credit"] += d.credit or 0
by_category_dict[cat]["tva"] += d.tva or 0
by_category_dict[cat]["locatif"] += d.locatif or 0
by_category_dict[cat]["deductible"] += d.deductible or 0
by_category = [
CategorySummary(
categorie=cat,
count=data["count"],
total_debit=data["debit"],
total_credit=data["credit"],
total_tva=data["tva"],
total_locatif=data["locatif"],
total_deductible=data["deductible"],
)
for cat, data in sorted(by_category_dict.items(), key=lambda x: -x[1]["debit"])
]
# Aggregate by tag
by_tag_dict = defaultdict(lambda: {"tag_nom": None, "count": 0, "debit": 0.0})
for d in depenses:
tag_key = d.tag_id or 0 # 0 for untagged
by_tag_dict[tag_key]["count"] += 1
by_tag_dict[tag_key]["debit"] += d.debit or 0
if d.tag_id and d.tag:
by_tag_dict[tag_key]["tag_nom"] = d.tag.nom
by_tag = [
TagSummary(
tag_id=tag_id if tag_id != 0 else None,
tag_nom=data["tag_nom"] if tag_id != 0 else "Non taggue",
count=data["count"],
total_debit=data["debit"],
)
for tag_id, data in sorted(by_tag_dict.items(), key=lambda x: -x[1]["debit"])
]
# Aggregate by month
by_month_dict = defaultdict(lambda: {"count": 0, "debit": 0.0, "credit": 0.0})
for d in depenses:
doc_date = doc_dates.get(d.document_id)
if doc_date:
month_key = (doc_date.year, doc_date.month)
by_month_dict[month_key]["count"] += 1
by_month_dict[month_key]["debit"] += d.debit or 0
by_month_dict[month_key]["credit"] += d.credit or 0
by_month = [
MonthlySummary(
year=year,
month=month,
count=data["count"],
total_debit=data["debit"],
total_credit=data["credit"],
)
for (year, month), data in sorted(by_month_dict.items())
]
# Aggregate by fournisseur (top 20)
by_fournisseur_dict = defaultdict(lambda: {"count": 0, "debit": 0.0})
for d in depenses:
fournisseur_key = d.fournisseur or "Non specifie"
by_fournisseur_dict[fournisseur_key]["count"] += 1
by_fournisseur_dict[fournisseur_key]["debit"] += d.debit or 0
by_fournisseur = [
FournisseurSummary(
fournisseur=fournisseur if fournisseur != "Non specifie" else None,
count=data["count"],
total_debit=data["debit"],
)
for fournisseur, data in sorted(
by_fournisseur_dict.items(), key=lambda x: -x[1]["debit"]
)[:20]
]
return DepensesSummary(
total_count=total_count,
total_debit=total_debit,
total_credit=total_credit,
total_tva=total_tva,
total_locatif=total_locatif,
total_deductible=total_deductible,
by_category=by_category,
by_tag=by_tag,
by_month=by_month,
by_fournisseur=by_fournisseur,
)
@router.get("/analytics/categories")
async def list_categories(
session: Session = Depends(get_session),
) -> list[str]:
"""Liste toutes les categories de depenses distinctes."""
stmt = (
select(distinct(Depense.categorie))
.where(Depense.categorie.isnot(None))
.order_by(Depense.categorie)
)
result = session.execute(stmt)
return [row[0] for row in result.all() if row[0]]

View File

@@ -0,0 +1,191 @@
"""Documents routes - CRUD operations for documents."""
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func, select
from ...database import get_session, DatabaseService
from ...database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
from ...database.service import DuplicateDocumentError
from ..schemas import SaveRequest, SaveResponse, DocumentSummary
router = APIRouter(prefix="/api", tags=["documents"])
@router.post("/save", response_model=SaveResponse)
async def save_document(
request: SaveRequest,
session: Session = Depends(get_session),
) -> SaveResponse:
"""Sauvegarde les donnees extraites en base de donnees.
- **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).
"""
try:
db_service = DatabaseService(session)
document = db_service.save_document(
data=request.data,
source_file=request.source_file,
depenses_tags=request.depenses_tags,
overwrite=request.overwrite,
)
return SaveResponse(
success=True,
message="Document sauvegarde avec succes",
document_id=document.id,
reference=document.reference,
date=str(document.date),
)
except DuplicateDocumentError as e:
return SaveResponse(
success=False,
message=f"Document deja existant: reference={e.reference}, date={e.date}",
reference=e.reference,
date=str(e.date),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Erreur lors de la sauvegarde: {str(e)}"
)
@router.get("/stats")
async def get_stats(
session: Session = Depends(get_session),
) -> dict:
"""Retourne les statistiques globales de la base de donnees.
Compteurs pour chaque table principale.
"""
return {
"documents": session.execute(select(func.count(Document.id))).scalar() or 0,
"immeubles": session.execute(select(func.count(Immeuble.id))).scalar() or 0,
"lots": session.execute(select(func.count(Lot.id))).scalar() or 0,
"locataires": session.execute(select(func.count(Locataire.id))).scalar() or 0,
"revenus": session.execute(select(func.count(Revenu.id))).scalar() or 0,
"depenses": session.execute(select(func.count(Depense.id))).scalar() or 0,
}
@router.get("/documents", response_model=list[DocumentSummary])
async def list_documents(
limit: int = 100,
offset: int = 0,
session: Session = Depends(get_session),
) -> list[DocumentSummary]:
"""Liste les documents importes en base.
- **limit**: Nombre maximum de documents a retourner (defaut: 100)
- **offset**: Decalage pour la pagination (defaut: 0)
Retourne une liste de documents avec leurs metadonnees principales.
"""
db_service = DatabaseService(session)
documents = db_service.list_documents(limit=limit, offset=offset)
return [
DocumentSummary(
id=doc.id,
reference=doc.reference,
date=str(doc.date),
type=doc.type,
source_file=doc.source_file,
immeuble_code=doc.immeuble.code if doc.immeuble else None,
immeuble_adresse=doc.immeuble.adresse if doc.immeuble else None,
solde_montant=doc.solde_montant,
solde_type=doc.solde_type,
created_at=doc.created_at.isoformat() if doc.created_at else None,
)
for doc in documents
]
@router.get("/documents/{document_id}")
async def get_document(
document_id: int,
session: Session = Depends(get_session),
) -> dict:
"""Recupere un document par son ID avec toutes ses donnees.
Retourne le document complet incluant le JSON original.
"""
db_service = DatabaseService(session)
document = db_service.get_document_by_id(document_id)
if not document:
raise HTTPException(status_code=404, detail="Document non trouve")
return {
"id": document.id,
"reference": document.reference,
"date": str(document.date),
"type": document.type,
"source_file": document.source_file,
"immeuble": {
"code": document.immeuble.code,
"adresse": document.immeuble.adresse,
"ville": document.immeuble.ville,
"code_postal": document.immeuble.code_postal,
}
if document.immeuble
else None,
"solde": {
"montant": document.solde_montant,
"type": document.solde_type,
"date_arrete": str(document.solde_date_arrete)
if document.solde_date_arrete
else None,
},
"editeur": {
"nom": document.editeur_nom,
"siret": document.editeur_siret,
},
"json_data": json.loads(document.json_data) if document.json_data else None,
"created_at": document.created_at.isoformat() if document.created_at else None,
}
@router.get("/check-duplicate")
async def check_duplicate(
reference: str,
date: str,
session: Session = Depends(get_session),
) -> dict:
"""Verifie si un document existe deja en base.
- **reference**: Reference du document
- **date**: Date du document (format YYYY-MM-DD)
Retourne {exists: true/false, document_id: ...}
"""
try:
doc_date = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(
status_code=400, detail="Format de date invalide. Utiliser YYYY-MM-DD"
)
db_service = DatabaseService(session)
existing = db_service.check_duplicate(reference, doc_date)
return {
"exists": existing is not None,
"document_id": existing.id if existing else None,
"reference": reference,
"date": date,
}

View File

@@ -0,0 +1,76 @@
"""Extraction routes - PDF upload and data extraction."""
import tempfile
from pathlib import Path
from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from ...extractor import extract_compte_rendu
router = APIRouter(prefix="/api", tags=["extraction"])
@router.post("/extract")
async def extract_pdf(
file: UploadFile = File(..., description="Fichier PDF de compte rendu de gérance"),
) -> JSONResponse:
"""Extrait les données d'un PDF de compte rendu de gérance.
Upload un fichier PDF et retourne les données structurées en JSON.
- **file**: Fichier PDF à analyser (Content-Type: multipart/form-data)
Retourne un objet JSON contenant:
- **source_file**: Nom du fichier uploadé
- **data**: Données extraites (metadata, situation_locataires, recapitulatif_operations)
"""
# Validation du type de fichier
if not file.filename:
raise HTTPException(status_code=400, detail="Nom de fichier manquant")
if not file.filename.lower().endswith(".pdf"):
raise HTTPException(
status_code=400,
detail=f"Le fichier doit être un PDF. Reçu: {file.filename}",
)
# Validation du content-type (si fourni)
if file.content_type and file.content_type not in (
"application/pdf",
"application/octet-stream",
):
raise HTTPException(
status_code=400,
detail=f"Content-Type invalide. Attendu: application/pdf, reçu: {file.content_type}",
)
tmp_path: Path | None = None
# Sauvegarde temporaire du fichier uploadé
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
content = await file.read()
tmp_file.write(content)
tmp_path = Path(tmp_file.name)
# Extraction des données
try:
data = extract_compte_rendu(str(tmp_path))
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Erreur lors de l'extraction du PDF: {str(e)}",
)
return JSONResponse(
content={
"source_file": file.filename,
"data": data,
}
)
finally:
# Nettoyage du fichier temporaire
if tmp_path and tmp_path.exists():
tmp_path.unlink()

View File

@@ -0,0 +1,58 @@
"""Tags routes - Tag management and prediction."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ...database import get_session, DatabaseService
from ...services.tag_predictor import TagPredictor
from ..schemas import PredictTagsRequest
router = APIRouter(prefix="/api", tags=["tags"])
@router.get("/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]
@router.post("/predict-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)}"
)

View File

@@ -0,0 +1,23 @@
"""Pydantic schemas for API request/response models."""
from .models import (
SaveRequest,
SaveResponse,
PredictTagsRequest,
DocumentSummary,
DepenseDetail,
DepensesSummary,
ImmeubleResponse,
LotResponse,
)
__all__ = [
"SaveRequest",
"SaveResponse",
"PredictTagsRequest",
"DocumentSummary",
"DepenseDetail",
"DepensesSummary",
"ImmeubleResponse",
"LotResponse",
]

View File

@@ -0,0 +1,178 @@
"""Pydantic models for API requests and responses."""
from datetime import date
from typing import Any
from pydantic import BaseModel
# ============================================================
# Requests
# ============================================================
class SaveRequest(BaseModel):
"""Request body for saving extracted data."""
source_file: str | None = None
data: dict[str, Any]
depenses_tags: list[dict] | None = None
overwrite: bool = False
class PredictTagsRequest(BaseModel):
"""Request body for predicting tags."""
depenses: list[dict]
# ============================================================
# Responses - Documents
# ============================================================
class SaveResponse(BaseModel):
"""Response for save operation."""
success: bool
message: str
document_id: int | None = None
reference: str | None = None
date: str | None = None
class DocumentSummary(BaseModel):
"""Summary of a saved document."""
id: int
reference: str
date: str
type: str | None
source_file: str | None
immeuble_code: str | None
immeuble_adresse: str | None
solde_montant: float | None
solde_type: str | None
created_at: str
# ============================================================
# Responses - Analytics
# ============================================================
class ImmeubleResponse(BaseModel):
"""Response for immeuble listing."""
id: int
code: str
adresse: str | None
ville: str | None
code_postal: str | None
nb_lots: int = 0
nb_depenses: int = 0
class LotResponse(BaseModel):
"""Response for lot listing."""
id: int
numero: str
type: str | None
immeuble_id: int
immeuble_code: str | None
class TagResponse(BaseModel):
"""Response for tag listing."""
id: int
nom: str
nb_depenses: int = 0
class FournisseurResponse(BaseModel):
"""Response for fournisseur listing."""
nom: str
nb_depenses: int
total_debit: float
class DepenseDetail(BaseModel):
"""Detailed depense for analytics."""
id: int
document_id: int
document_date: date | None
document_reference: str | None
immeuble_id: int
immeuble_code: str | None
immeuble_adresse: str | None
lot_id: int | None
lot_numero: str | None
tag_id: int | None
tag_nom: str | None
categorie: str | None
sous_categorie: str | None
fournisseur: str | None
description: str | None
debit: float
credit: float
tva: float
locatif: float
deductible: float
class CategorySummary(BaseModel):
"""Summary for a category."""
categorie: str
count: int
total_debit: float
total_credit: float
total_tva: float
total_locatif: float
total_deductible: float
class TagSummary(BaseModel):
"""Summary for a tag."""
tag_id: int | None
tag_nom: str | None
count: int
total_debit: float
class MonthlySummary(BaseModel):
"""Summary for a month."""
year: int
month: int
count: int
total_debit: float
total_credit: float
class FournisseurSummary(BaseModel):
"""Summary for a fournisseur."""
fournisseur: str | None
count: int
total_debit: float
class DepensesSummary(BaseModel):
"""Aggregated summary of depenses for analytics."""
total_count: int
total_debit: float
total_credit: float
total_tva: float
total_locatif: float
total_deductible: float
by_category: list[CategorySummary]
by_tag: list[TagSummary]
by_month: list[MonthlySummary]
by_fournisseur: list[FournisseurSummary]