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",
|
||||
"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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
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 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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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