feat: add analytics
This commit is contained in:
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -8,8 +8,10 @@
|
|||||||
"name": "plesna-gerance-frontend",
|
"name": "plesna-gerance-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.4.1",
|
||||||
"pdfjs-dist": "^4.0.379",
|
"pdfjs-dist": "^4.0.379",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
|
"vue-chartjs": "^5.3.0",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -508,6 +510,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/canvas": {
|
||||||
"version": "0.1.88",
|
"version": "0.1.88",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz",
|
||||||
@@ -1439,6 +1447,18 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"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": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.6.4",
|
"version": "4.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.4.1",
|
||||||
"pdfjs-dist": "^4.0.379",
|
"pdfjs-dist": "^4.0.379",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
|
"vue-chartjs": "^5.3.0",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
Accueil
|
Accueil
|
||||||
</router-link>
|
</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 -->
|
<!-- Import button with file input -->
|
||||||
<label
|
<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"
|
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"
|
||||||
|
|||||||
92
frontend/src/components/analytics/CategoryChart.vue
Normal file
92
frontend/src/components/analytics/CategoryChart.vue
Normal 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>
|
||||||
217
frontend/src/components/analytics/DepensesTable.vue
Normal file
217
frontend/src/components/analytics/DepensesTable.vue
Normal 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"
|
||||||
|
>
|
||||||
|
<<
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
>>
|
||||||
|
</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>
|
||||||
208
frontend/src/components/analytics/FilterPanel.vue
Normal file
208
frontend/src/components/analytics/FilterPanel.vue
Normal 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>
|
||||||
49
frontend/src/components/analytics/KpiCards.vue
Normal file
49
frontend/src/components/analytics/KpiCards.vue
Normal 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>
|
||||||
107
frontend/src/components/analytics/MonthlyChart.vue
Normal file
107
frontend/src/components/analytics/MonthlyChart.vue
Normal 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>
|
||||||
89
frontend/src/components/analytics/TopFournisseursChart.vue
Normal file
89
frontend/src/components/analytics/TopFournisseursChart.vue
Normal 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>
|
||||||
143
frontend/src/pages/AnalyticsPage.vue
Normal file
143
frontend/src/pages/AnalyticsPage.vue
Normal 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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import HomePage from './pages/HomePage.vue'
|
import HomePage from './pages/HomePage.vue'
|
||||||
import ExtractPage from './pages/ExtractPage.vue'
|
import ExtractPage from './pages/ExtractPage.vue'
|
||||||
|
import AnalyticsPage from './pages/AnalyticsPage.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -12,6 +13,11 @@ const routes = [
|
|||||||
path: '/extract',
|
path: '/extract',
|
||||||
name: 'extract',
|
name: 'extract',
|
||||||
component: ExtractPage
|
component: ExtractPage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/analytics',
|
||||||
|
name: 'analytics',
|
||||||
|
component: AnalyticsPage
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
"""Application FastAPI pour l'extraction de comptes rendus de gérance."""
|
"""Application FastAPI pour l'extraction de comptes rendus de gérance."""
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import FastAPI, File, HTTPException, UploadFile, Depends
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import JSONResponse, FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
from ..extractor import extract_compte_rendu
|
from ..database import init_db
|
||||||
from ..database import init_db, get_session, DatabaseService
|
from .routes import extraction_router, documents_router, tags_router, analytics_router
|
||||||
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",
|
||||||
@@ -33,51 +27,14 @@ async def startup_event():
|
|||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
# Pydantic models for API
|
# Include routers
|
||||||
class SaveRequest(BaseModel):
|
app.include_router(extraction_router)
|
||||||
"""Request body for saving extracted data."""
|
app.include_router(documents_router)
|
||||||
|
app.include_router(tags_router)
|
||||||
source_file: str | None = None
|
app.include_router(analytics_router)
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
|
# Health check endpoints (keep in main app)
|
||||||
@app.get("/api", tags=["health"])
|
@app.get("/api", tags=["health"])
|
||||||
async def api_root() -> dict:
|
async def api_root() -> dict:
|
||||||
"""Endpoint racine de l'API - informations."""
|
"""Endpoint racine de l'API - informations."""
|
||||||
@@ -94,300 +51,8 @@ async def health() -> dict:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/extract", tags=["extraction"])
|
# Determine the frontend dist path
|
||||||
async def extract_pdf(
|
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
|
||||||
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)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Mount static files for production (if dist exists)
|
# Mount static files for production (if dist exists)
|
||||||
|
|||||||
13
src/plesna_gerance/api/routes/__init__.py
Normal file
13
src/plesna_gerance/api/routes/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
438
src/plesna_gerance/api/routes/analytics.py
Normal file
438
src/plesna_gerance/api/routes/analytics.py
Normal 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]]
|
||||||
191
src/plesna_gerance/api/routes/documents.py
Normal file
191
src/plesna_gerance/api/routes/documents.py
Normal 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,
|
||||||
|
}
|
||||||
76
src/plesna_gerance/api/routes/extraction.py
Normal file
76
src/plesna_gerance/api/routes/extraction.py
Normal 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()
|
||||||
58
src/plesna_gerance/api/routes/tags.py
Normal file
58
src/plesna_gerance/api/routes/tags.py
Normal 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)}"
|
||||||
|
)
|
||||||
23
src/plesna_gerance/api/schemas/__init__.py
Normal file
23
src/plesna_gerance/api/schemas/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
178
src/plesna_gerance/api/schemas/models.py
Normal file
178
src/plesna_gerance/api/schemas/models.py
Normal 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]
|
||||||
Reference in New Issue
Block a user