diff --git a/frontend/src/components/dashboard/AnnualChart.vue b/frontend/src/components/dashboard/AnnualChart.vue new file mode 100644 index 0000000..8c7204f --- /dev/null +++ b/frontend/src/components/dashboard/AnnualChart.vue @@ -0,0 +1,137 @@ + + + diff --git a/frontend/src/components/dashboard/DepensesParTag.vue b/frontend/src/components/dashboard/DepensesParTag.vue new file mode 100644 index 0000000..035dc3d --- /dev/null +++ b/frontend/src/components/dashboard/DepensesParTag.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/components/dashboard/FinancialSummary.vue b/frontend/src/components/dashboard/FinancialSummary.vue deleted file mode 100644 index 631c77a..0000000 --- a/frontend/src/components/dashboard/FinancialSummary.vue +++ /dev/null @@ -1,181 +0,0 @@ - - - diff --git a/frontend/src/components/dashboard/MiniTrendChart.vue b/frontend/src/components/dashboard/MiniTrendChart.vue deleted file mode 100644 index e2bf6ce..0000000 --- a/frontend/src/components/dashboard/MiniTrendChart.vue +++ /dev/null @@ -1,146 +0,0 @@ - - - diff --git a/frontend/src/components/dashboard/PeriodeBandeau.vue b/frontend/src/components/dashboard/PeriodeBandeau.vue new file mode 100644 index 0000000..2b5cd0e --- /dev/null +++ b/frontend/src/components/dashboard/PeriodeBandeau.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/components/dashboard/RecentRevenus.vue b/frontend/src/components/dashboard/RecentRevenus.vue deleted file mode 100644 index f91b25c..0000000 --- a/frontend/src/components/dashboard/RecentRevenus.vue +++ /dev/null @@ -1,100 +0,0 @@ - - - diff --git a/frontend/src/components/dashboard/ResumeTuiles.vue b/frontend/src/components/dashboard/ResumeTuiles.vue new file mode 100644 index 0000000..e6f04b9 --- /dev/null +++ b/frontend/src/components/dashboard/ResumeTuiles.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index 9da021a..dfac162 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.vue @@ -2,6 +2,13 @@
+ +

Glisser un PDF ici ou cliquer pour importer - Deposer le fichier ici + Déposer le fichier ici

- + - - + +
+
+
+
+
+ + +
- - -
- - -
-
- + + + +
- -
-
-

Derniers documents importes

- {{ documents.length }} documents -
+ +
+
+

Derniers documents importés

+ {{ documents.length }} affichés +
-
-
-
+
+
+
-
- - - -

Aucun document importe.

- -
- -
-
-
-
-
- {{ doc.reference }} - - {{ doc.immeuble_code }} - -
-
- {{ formatDate(doc.date) }} - {{ doc.immeuble_adresse }} -
-
-
- - {{ formatAmount(doc.solde_montant) }} +
+ + + +

Aucun document importé.

+ +
+ +
+
+
+
+
+ {{ doc.reference }} + + {{ doc.immeuble_code }}
+
+ {{ formatDate(doc.date) }} + {{ doc.immeuble_adresse }} +
+
+
+ + {{ formatMontantPrecis(doc.solde_montant) }} +
- +
@@ -122,11 +138,13 @@ import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import { pendingFile } from '../store' import { isSoldeCrediteur } from '../utils/solde' +import { formatDate, formatMontantPrecis } from '../utils/format' import QuickActions from '../components/dashboard/QuickActions.vue' -import FinancialSummary from '../components/dashboard/FinancialSummary.vue' -import RecentRevenus from '../components/dashboard/RecentRevenus.vue' -import MiniTrendChart from '../components/dashboard/MiniTrendChart.vue' +import PeriodeBandeau from '../components/dashboard/PeriodeBandeau.vue' +import ResumeTuiles from '../components/dashboard/ResumeTuiles.vue' +import AnnualChart from '../components/dashboard/AnnualChart.vue' +import DepensesParTag from '../components/dashboard/DepensesParTag.vue' import ImmeubleShortcuts from '../components/dashboard/ImmeubleShortcuts.vue' const router = useRouter() @@ -135,101 +153,93 @@ const isDragging = ref(false) // Loading states const isLoadingDocs = ref(true) -const isLoadingRevenus = ref(true) -const isLoadingTrends = ref(true) +const isLoadingResume = ref(true) +const isLoadingDepenses = ref(true) const isLoadingImmeubles = ref(true) // Data const documents = ref([]) -const financialSummary = ref({ - last_document_date: null, - last_document_reference: null, - revenus: 0, - impayes: 0, - depenses: 0, - solde: 0, - revenus_history: [], - impayes_history: [], - depenses_history: [], - solde_history: [] -}) -const recentRevenus = ref([]) -const monthlyTrends = ref([]) +const depensesParTag = ref([]) const immeubleShortcuts = ref([]) +const resume = ref({ + annee: new Date().getFullYear(), + annees_disponibles: [], + perimetre: { + nb_comptes_rendus: 0, + premiere_date: null, + derniere_date: null, + nb_immeubles: 0, + nb_lots_factures: 0 + }, + facture: 0, + encaisse: 0, + taux_recouvrement: 100, + depenses_debit: 0, + depenses_credit: 0, + net_reverse: 0, + soldes_annonces: 0, + ecart_soldes: 0, + restant_du: 0, + restant_du_date: null, + par_mois: [] +}) -function formatDate(dateStr) { - if (!dateStr) return '-' - const [year, month, day] = dateStr.split('-') - return `${day}/${month}/${year}` +async function chargerJson(url) { + try { + const reponse = await fetch(url) + return reponse.ok ? await reponse.json() : null + } catch (err) { + console.error(`Echec du chargement de ${url}:`, err) + return null + } } -function formatAmount(amount) { - if (amount == null) return '-' - return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount) +/** + * Le resume est charge en premier : il fixe l'annee affichee, dont depend le + * cadrage des depenses et des raccourcis. Les charger avant reviendrait a + * afficher trois periodes differentes sur le meme ecran. + */ +async function chargerResume(annee) { + isLoadingResume.value = true + const url = annee + ? `/api/dashboard/resume-annuel?annee=${annee}` + : '/api/dashboard/resume-annuel' + const data = await chargerJson(url) + if (data) resume.value = data + isLoadingResume.value = false + return resume.value.annee } -async function loadData() { - // Charger les documents - isLoadingDocs.value = true - try { - const docsRes = await fetch('/api/documents?limit=5') - if (docsRes.ok) { - documents.value = await docsRes.json() - } - } catch (err) { - console.error('Failed to load documents:', err) - } finally { - isLoadingDocs.value = false - } +async function chargerDepenses(annee) { + isLoadingDepenses.value = true + const data = await chargerJson( + `/api/analytics/depenses/summary?date_debut=${annee}-01-01&date_fin=${annee}-12-31` + ) + depensesParTag.value = data?.by_tag ?? [] + isLoadingDepenses.value = false +} - // Charger le resume financier - try { - const summaryRes = await fetch('/api/dashboard/financial-summary') - if (summaryRes.ok) { - financialSummary.value = await summaryRes.json() - } - } catch (err) { - console.error('Failed to load financial summary:', err) - } - - // Charger les revenus recents - isLoadingRevenus.value = true - try { - const revenusRes = await fetch('/api/dashboard/recent-revenus?limit=5') - if (revenusRes.ok) { - recentRevenus.value = await revenusRes.json() - } - } catch (err) { - console.error('Failed to load recent revenus:', err) - } finally { - isLoadingRevenus.value = false - } - - // Charger les tendances mensuelles - isLoadingTrends.value = true - try { - const trendsRes = await fetch('/api/dashboard/monthly-trends?months=6') - if (trendsRes.ok) { - monthlyTrends.value = await trendsRes.json() - } - } catch (err) { - console.error('Failed to load monthly trends:', err) - } finally { - isLoadingTrends.value = false - } - - // Charger les raccourcis immeubles +async function chargerImmeubles(annee) { isLoadingImmeubles.value = true - try { - const immeublesRes = await fetch('/api/dashboard/immeubles-shortcuts?limit=6') - if (immeublesRes.ok) { - immeubleShortcuts.value = await immeublesRes.json() - } - } catch (err) { - console.error('Failed to load immeubles shortcuts:', err) - } finally { - isLoadingImmeubles.value = false - } + const data = await chargerJson(`/api/dashboard/immeubles-shortcuts?annee=${annee}&limit=6`) + immeubleShortcuts.value = data ?? [] + isLoadingImmeubles.value = false +} + +async function chargerDocuments() { + isLoadingDocs.value = true + const data = await chargerJson('/api/documents?limit=5') + documents.value = data ?? [] + isLoadingDocs.value = false +} + +async function chargerAnnee(annee) { + const affichee = await chargerResume(annee) + await Promise.all([chargerDepenses(affichee), chargerImmeubles(affichee)]) +} + +function changerAnnee(annee) { + chargerAnnee(annee) } function triggerFileInput() { @@ -263,6 +273,7 @@ function goToAnalytics() { } onMounted(() => { - loadData() + chargerDocuments() + chargerAnnee(null) }) diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js new file mode 100644 index 0000000..1118aec --- /dev/null +++ b/frontend/src/utils/format.js @@ -0,0 +1,56 @@ +/** + * Mises en forme partagées des montants et des dates. + * + * Chaque composant redéfinissait son `formatAmount`, avec des variantes + * d'arrondi et de séparateur : deux écrans pouvaient écrire différemment le + * même euro. Les nouveaux affichages passent par ici. + */ + +const EUROS = new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 0 +}) + +const EUROS_PRECIS = new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency: 'EUR' +}) + +const MOIS_COURTS = [ + 'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', + 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.' +] + +/** Montant arrondi à l'euro, pour les chiffres qu'on lit d'un coup d'œil. */ +export function formatMontant(valeur) { + if (valeur == null) return '—' + return EUROS.format(valeur) +} + +/** Montant au centime, pour les écarts et les rapprochements. */ +export function formatMontantPrecis(valeur) { + if (valeur == null) return '—' + return EUROS_PRECIS.format(valeur) +} + +/** Montant signé, le « + » explicite pour ce qui revient au propriétaire. */ +export function formatEcart(valeur) { + if (valeur == null) return '—' + const signe = valeur > 0 ? '+' : '' + return `${signe}${EUROS_PRECIS.format(valeur)}` +} + +/** "2026-06-22" → "22/06/2026" */ +export function formatDate(chaine) { + if (!chaine) return '—' + const [annee, mois, jour] = chaine.split('-') + return `${jour}/${mois}/${annee}` +} + +/** "2026-06" → "juin" (l'année est portée par le titre de la période) */ +export function formatMois(chaine) { + if (!chaine) return '' + const [, mois] = chaine.split('-') + return MOIS_COURTS[parseInt(mois, 10) - 1] ?? chaine +} diff --git a/src/plesna_gerance/api/routes/dashboard.py b/src/plesna_gerance/api/routes/dashboard.py index 39947b9..10b0f0c 100644 --- a/src/plesna_gerance/api/routes/dashboard.py +++ b/src/plesna_gerance/api/routes/dashboard.py @@ -1,9 +1,8 @@ -"""Dashboard routes - Aggregated data for the home page.""" +"""Dashboard routes - Vue annuelle des revenus et des depenses.""" -from collections import defaultdict -from datetime import date, timedelta +from datetime import date -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from sqlalchemy import desc, func, select from sqlalchemy.orm import Session @@ -17,7 +16,13 @@ from ...database.models import ( Lot, Revenu, ) -from ...services.revenus_query import est_flux, flux_par, restant_du_par +from ...services.revenus_query import ( + est_flux, + flux_par, + restant_du_par, + taux_de_recouvrement, +) +from ...services.tresorerie import depenses_par, net_reverse, somme_soldes_annonces router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) @@ -27,50 +32,52 @@ router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) # ============================================================ -class MonthlyDataPoint(BaseModel): - """Point de donnees mensuel pour sparkline.""" +class PerimetreResponse(BaseModel): + """De quoi l'annee affichee est faite.""" - month: str - value: float + nb_comptes_rendus: int + premiere_date: str | None = None + derniere_date: str | None = None + nb_immeubles: int + #: Lots effectivement factures dans l'annee. La table `lots` en contient + #: davantage : d'anciennes ecritures de numeros y subsistent sans revenu. + nb_lots_factures: int -class FinancialSummaryResponse(BaseModel): - """Resume financier du dernier document avec historique pour sparklines.""" +class MoisResponse(BaseModel): + """Un mois de l'annee, tel qu'il se compare aux autres.""" - # Valeurs du dernier document - last_document_date: str | None = None - last_document_reference: str | None = None - revenus: float - impayes: float + mois: str # "2026-06" + facture: float + encaisse: float depenses: float - solde: float - # Historique pour sparklines (6 derniers mois) - revenus_history: list[MonthlyDataPoint] = [] - impayes_history: list[MonthlyDataPoint] = [] - depenses_history: list[MonthlyDataPoint] = [] - solde_history: list[MonthlyDataPoint] = [] + net: float -class RecentRevenuResponse(BaseModel): - """Revenu recent avec details.""" +class ResumeAnnuelResponse(BaseModel): + """Revenus et depenses de l'annee civile, avec leur contrepartie annoncee.""" - id: int - document_date: str - locataire_nom: str - lot_numero: str - immeuble_code: str - total: float - impayes: float - type_ligne: str + annee: int + annees_disponibles: list[int] + perimetre: PerimetreResponse + # Flux cumules sur l'annee, report exclu (cf. services/revenus_query) + facture: float + encaisse: float + taux_recouvrement: float + depenses_debit: float + depenses_credit: float -class MonthlyTrendResponse(BaseModel): - """Tendance mensuelle pour graphique.""" + # Ce qui revient au proprietaire, et l'ecart avec les soldes des CR + net_reverse: float + soldes_annonces: float + ecart_soldes: float - month: str # "2024-01" - revenus: float - depenses: float - solde: float + # Stock : photo du dernier compte rendu de l'annee, non cumulable + restant_du: float + restant_du_date: str | None = None + + par_mois: list[MoisResponse] = [] class ImmeubleShortcutResponse(BaseModel): @@ -91,255 +98,200 @@ class ImmeubleShortcutResponse(BaseModel): # ============================================================ -@router.get("/financial-summary", response_model=FinancialSummaryResponse) -async def get_financial_summary( - session: Session = Depends(get_session), -) -> FinancialSummaryResponse: - """Retourne le resume financier du dernier document avec historique pour sparklines. +def _annees_disponibles(session: Session) -> list[int]: + """Annees ayant au moins un compte rendu, de la plus recente a la plus ancienne.""" + # `select(colonne).distinct()` plutot que `func.distinct(colonne)` : le + # second perd le type de la colonne et rendrait les dates sous forme de + # chaines, sans annee a lire. + dates = session.execute(select(Document.date).distinct()).scalars().all() + return sorted({jour.year for jour in dates}, reverse=True) - - Valeurs principales basees sur le dernier document importe - - Historique sur 6 mois pour les sparklines + +def _annee_par_defaut(session: Session, annee: int | None) -> tuple[int, list[int]]: + """Annee a afficher et annees proposables. + + Le defaut est l'annee du dernier compte rendu, et non l'annee en cours : + sans import depuis quelques mois, un accueil cale sur la date du jour se + viderait alors que la base est pleine. """ - # Recuperer le dernier document - last_doc_stmt = select(Document).order_by(desc(Document.date)).limit(1) - last_doc = session.execute(last_doc_stmt).scalar() + annees = _annees_disponibles(session) + if annee is None: + annee = annees[0] if annees else date.today().year + return annee, annees - # Valeurs du dernier document - revenus = 0.0 - impayes = 0.0 - depenses = 0.0 - last_document_date = None - last_document_reference = None - if last_doc: - last_document_date = str(last_doc.date) - last_document_reference = last_doc.reference +@router.get("/resume-annuel", response_model=ResumeAnnuelResponse) +async def get_resume_annuel( + annee: int | None = Query(None, description="Annee civile a afficher"), + session: Session = Depends(get_session), +) -> ResumeAnnuelResponse: + """Retourne les revenus et depenses d'une annee civile. - # Revenus du dernier document : le solde reporte du mois precedent n'est - # pas un revenu du mois, il est deja compte dans les impayes. - revenus = ( - session.execute( - select(func.sum(Revenu.total)) - .where(Revenu.document_id == last_doc.id) - .where(est_flux()) - ).scalar() - or 0.0 + Les montants cumulables (facture, encaisse, depenses) sont bornes a l'annee ; + le restant du est lu dans le dernier compte rendu de la periode, car un stock + ne s'additionne pas dans le temps (cf. services/revenus_query). + """ + annee, annees = _annee_par_defaut(session, annee) + debut = date(annee, 1, 1) + fin = date(annee, 12, 31) + + perimetre = _perimetre(session, debut, fin) + + # ========== Flux de l'annee ========== + flux = flux_par(Document.immeuble_id, debut, fin) + totaux = session.execute( + select( + func.sum(flux.c.facture).label("facture"), + func.sum(flux.c.encaisse).label("encaisse"), + func.sum(flux.c.facture_regle).label("facture_regle"), ) + ).one() - # Impayes du dernier document - impayes = ( - session.execute( - select(func.sum(Revenu.impayes)).where( - Revenu.document_id == last_doc.id - ) - ).scalar() - or 0.0 + depenses = depenses_par(Document.immeuble_id, debut, fin) + cumul = session.execute( + select( + func.sum(depenses.c.debit).label("debit"), + func.sum(depenses.c.credit).label("credit"), ) + ).one() - # Depenses du dernier document - depenses = ( - session.execute( - select(func.sum(Depense.debit)).where( - Depense.document_id == last_doc.id - ) - ).scalar() - or 0.0 - ) + facture = totaux.facture or 0.0 + encaisse = totaux.encaisse or 0.0 + debit = cumul.debit or 0.0 + credit = cumul.credit or 0.0 - solde = revenus - depenses + net = net_reverse(encaisse, debit, credit) + annonces = somme_soldes_annonces(session, debut, fin) - # Historique sur 6 mois pour sparklines - today = date.today() - start_date = (today.replace(day=1) - timedelta(days=6 * 31)).replace(day=1) + # ========== Restant du (stock) ========== + stock = restant_du_par(Document.immeuble_id, debut, fin) + restant_du = session.execute(select(func.sum(stock.c.restant_du))).scalar() or 0.0 - # Revenus par mois - revenus_by_month: dict[str, float] = defaultdict(float) - impayes_by_month: dict[str, float] = defaultdict(float) - depenses_by_month: dict[str, float] = defaultdict(float) + return ResumeAnnuelResponse( + annee=annee, + annees_disponibles=annees, + perimetre=perimetre, + facture=round(facture, 2), + encaisse=round(encaisse, 2), + taux_recouvrement=taux_de_recouvrement(facture, totaux.facture_regle), + depenses_debit=round(debit, 2), + depenses_credit=round(credit, 2), + net_reverse=net, + soldes_annonces=annonces, + ecart_soldes=round(net - annonces, 2), + restant_du=round(restant_du, 2), + restant_du_date=perimetre.derniere_date, + par_mois=_par_mois(session, debut, fin), + ) - # Recuperer revenus et impayes par mois. Le revenu du mois exclut le report - # pour que les mois soient comparables ; l'impaye reste le solde constate ce - # mois-la, de sorte que la courbe suive la dette au lieu de l'empiler. - revenus_stmt = ( + +def _perimetre(session: Session, debut: date, fin: date) -> PerimetreResponse: + """Ce que couvre l'annee : combien de comptes rendus, sur quoi, jusqu'a quand.""" + documents = session.execute( + select( + func.count(Document.id).label("nb"), + func.min(Document.date).label("premiere"), + func.max(Document.date).label("derniere"), + func.count(func.distinct(Document.immeuble_id)).label("nb_immeubles"), + ).where(Document.date.between(debut, fin)) + ).one() + + # Les lots comptes sont ceux qui ont ete factures dans l'annee : un lot sorti + # de la gestion ne fait plus partie du perimetre qu'on resume, et la table + # `lots` retient par ailleurs d'anciennes ecritures de numeros sans revenu. + nb_lots_factures = ( + session.execute( + select(func.count(func.distinct(Revenu.lot_id))) + .join(Document, Revenu.document_id == Document.id) + .where(Document.date.between(debut, fin)) + ).scalar() + or 0 + ) + + return PerimetreResponse( + nb_comptes_rendus=documents.nb or 0, + premiere_date=str(documents.premiere) if documents.premiere else None, + derniere_date=str(documents.derniere) if documents.derniere else None, + nb_immeubles=documents.nb_immeubles or 0, + nb_lots_factures=nb_lots_factures, + ) + + +def _par_mois(session: Session, debut: date, fin: date) -> list[MoisResponse]: + """Detail mensuel de l'annee, un point par mois ayant un compte rendu. + + Les mois sans compte rendu sont omis plutot que mis a zero : un mois vide + est un mois non importe, pas un mois sans loyer, et une barre a zero le + ferait lire comme une chute d'activite. + + L'encaisse retient toutes les lignes, report compris : regler une vieille + dette est bien un encaissement du mois. Le facture, lui, exclut le report, + sans quoi les mois cesseraient d'etre comparables. + """ + revenus = session.execute( select( Document.date, - func.sum(Revenu.total).filter(est_flux()).label("total"), - func.sum(Revenu.impayes).label("impayes"), + func.sum(Revenu.total).filter(est_flux()).label("facture"), + func.sum(Revenu.regles).label("encaisse"), ) .join(Revenu, Revenu.document_id == Document.id) - .where(Document.date >= start_date) + .where(Document.date.between(debut, fin)) .group_by(Document.date) - ) - for row in session.execute(revenus_stmt): - month_key = row.date.strftime("%Y-%m") - revenus_by_month[month_key] += row.total or 0.0 - impayes_by_month[month_key] += row.impayes or 0.0 + ).all() - # Recuperer depenses par mois - depenses_stmt = ( - select(Document.date, func.sum(Depense.debit).label("total")) - .join(Depense, Depense.document_id == Document.id) - .where(Document.date >= start_date) - .group_by(Document.date) - ) - for row in session.execute(depenses_stmt): - month_key = row.date.strftime("%Y-%m") - depenses_by_month[month_key] += row.total or 0.0 - - # Generer les 6 derniers mois - all_months = [] - current = today.replace(day=1) - for _ in range(6): - all_months.insert(0, current.strftime("%Y-%m")) - current = (current - timedelta(days=1)).replace(day=1) - - # Construire les listes d'historique - revenus_history = [ - MonthlyDataPoint(month=m, value=revenus_by_month.get(m, 0.0)) - for m in all_months - ] - impayes_history = [ - MonthlyDataPoint(month=m, value=impayes_by_month.get(m, 0.0)) - for m in all_months - ] - depenses_history = [ - MonthlyDataPoint(month=m, value=depenses_by_month.get(m, 0.0)) - for m in all_months - ] - solde_history = [ - MonthlyDataPoint( - month=m, - value=revenus_by_month.get(m, 0.0) - depenses_by_month.get(m, 0.0), - ) - for m in all_months - ] - - return FinancialSummaryResponse( - last_document_date=last_document_date, - last_document_reference=last_document_reference, - revenus=revenus, - impayes=impayes, - depenses=depenses, - solde=solde, - revenus_history=revenus_history, - impayes_history=impayes_history, - depenses_history=depenses_history, - solde_history=solde_history, - ) - - -@router.get("/recent-revenus", response_model=list[RecentRevenuResponse]) -async def get_recent_revenus( - limit: int = 10, - session: Session = Depends(get_session), -) -> list[RecentRevenuResponse]: - """Retourne les derniers revenus/loyers enregistres. - - - **limit**: Nombre maximum de resultats (defaut: 10) - """ - stmt = ( + depenses = session.execute( select( - Revenu, - Document.date.label("document_date"), - Locataire.nom.label("locataire_nom"), - Lot.numero.label("lot_numero"), - Immeuble.code.label("immeuble_code"), + Document.date, + func.sum(Depense.debit).label("debit"), + func.sum(Depense.credit).label("credit"), ) - .join(Document, Revenu.document_id == Document.id) - .join(Locataire, Revenu.locataire_id == Locataire.id) - .join(Lot, Revenu.lot_id == Lot.id) - .join(Immeuble, Lot.immeuble_id == Immeuble.id) - .order_by(desc(Document.date), desc(Revenu.id)) - .limit(limit) - ) - - result = session.execute(stmt) - rows = result.all() - - return [ - RecentRevenuResponse( - id=row.Revenu.id, - document_date=str(row.document_date), - locataire_nom=row.locataire_nom, - lot_numero=row.lot_numero, - immeuble_code=row.immeuble_code, - total=row.Revenu.total or 0.0, - impayes=row.Revenu.impayes or 0.0, - type_ligne=row.Revenu.type_ligne or "", - ) - for row in rows - ] - - -@router.get("/monthly-trends", response_model=list[MonthlyTrendResponse]) -async def get_monthly_trends( - months: int = 6, - session: Session = Depends(get_session), -) -> list[MonthlyTrendResponse]: - """Retourne les tendances mensuelles pour le graphique. - - - **months**: Nombre de mois a inclure (defaut: 6) - """ - today = date.today() - start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1) - - # Recuperer tous les revenus depuis start_date, report exclu : la courbe - # compare des mois entre eux, pas des soldes cumules. - revenus_stmt = ( - select(Document.date, func.sum(Revenu.total).filter(est_flux()).label("total")) - .join(Revenu, Revenu.document_id == Document.id) - .where(Document.date >= start_date) - .group_by(Document.date) - ) - - revenus_result = session.execute(revenus_stmt) - revenus_by_month: dict[str, float] = defaultdict(float) - for row in revenus_result: - month_key = row.date.strftime("%Y-%m") - revenus_by_month[month_key] += row.total or 0.0 - - # Recuperer toutes les depenses depuis start_date - depenses_stmt = ( - select(Document.date, func.sum(Depense.debit).label("total")) .join(Depense, Depense.document_id == Document.id) - .where(Document.date >= start_date) + .where(Document.date.between(debut, fin)) .group_by(Document.date) - ) + ).all() - depenses_result = session.execute(depenses_stmt) - depenses_by_month: dict[str, float] = defaultdict(float) - for row in depenses_result: - month_key = row.date.strftime("%Y-%m") - depenses_by_month[month_key] += row.total or 0.0 - - # Combiner et trier - all_months = sorted(set(revenus_by_month.keys()) | set(depenses_by_month.keys())) - - # Limiter aux derniers mois demandes - all_months = all_months[-months:] + mois: dict[str, dict[str, float]] = {} + for ligne in revenus: + cumul = mois.setdefault(ligne.date.strftime("%Y-%m"), {}) + cumul["facture"] = cumul.get("facture", 0.0) + (ligne.facture or 0.0) + cumul["encaisse"] = cumul.get("encaisse", 0.0) + (ligne.encaisse or 0.0) + for ligne in depenses: + cumul = mois.setdefault(ligne.date.strftime("%Y-%m"), {}) + cumul["debit"] = cumul.get("debit", 0.0) + (ligne.debit or 0.0) + cumul["credit"] = cumul.get("credit", 0.0) + (ligne.credit or 0.0) return [ - MonthlyTrendResponse( - month=month, - revenus=revenus_by_month.get(month, 0.0), - depenses=depenses_by_month.get(month, 0.0), - solde=revenus_by_month.get(month, 0.0) - depenses_by_month.get(month, 0.0), + MoisResponse( + mois=cle, + facture=round(cumul.get("facture", 0.0), 2), + encaisse=round(cumul.get("encaisse", 0.0), 2), + depenses=round(cumul.get("debit", 0.0), 2), + net=net_reverse( + cumul.get("encaisse"), cumul.get("debit"), cumul.get("credit") + ), ) - for month in all_months + for cle, cumul in sorted(mois.items()) ] @router.get("/immeubles-shortcuts", response_model=list[ImmeubleShortcutResponse]) async def get_immeubles_shortcuts( + annee: int | None = Query(None, description="Annee civile a afficher"), limit: int = 5, session: Session = Depends(get_session), ) -> list[ImmeubleShortcutResponse]: """Retourne les immeubles pour acces rapide avec stats. - Trie par nombre de documents (plus actifs en premier). + Trie par nombre de documents (plus actifs en premier). Borne sur la meme + annee que le resume : deux perimetres sur un meme ecran donneraient deux + montants de revenus sans que rien ne les distingue. - **limit**: Nombre maximum d'immeubles (defaut: 5) """ + annee, _ = _annee_par_defaut(session, annee) + debut = date(annee, 1, 1) + fin = date(annee, 12, 31) + # Effectifs et activite, puis flux et restant du : trois granularites # differentes, jointes plutot que melangees pour ne pas se multiplier. effectifs = ( @@ -357,12 +309,15 @@ async def get_immeubles_shortcuts( Document.immeuble_id.label("immeuble_id"), func.count(Document.id).label("nb_documents"), ) + .where(Document.date.between(debut, fin)) .group_by(Document.immeuble_id) .subquery() ) - flux = flux_par(Document.immeuble_id) - stock = restant_du_par(Document.immeuble_id) + flux = flux_par(Document.immeuble_id, debut, fin) + stock = restant_du_par(Document.immeuble_id, debut, fin) + # Jointure fermee sur l'activite : un immeuble sans compte rendu cette + # annee-la n'a rien a montrer et sortirait avec des montants vides. stmt = ( select( Immeuble, @@ -371,8 +326,8 @@ async def get_immeubles_shortcuts( flux.c.facture, stock.c.restant_du, ) + .join(activite, activite.c.immeuble_id == Immeuble.id) .outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id) - .outerjoin(activite, activite.c.immeuble_id == Immeuble.id) .outerjoin(flux, flux.c.cle == Immeuble.id) .outerjoin(stock, stock.c.cle == Immeuble.id) .order_by(desc(activite.c.nb_documents)) diff --git a/src/plesna_gerance/services/tresorerie.py b/src/plesna_gerance/services/tresorerie.py new file mode 100644 index 0000000..3f0dcdf --- /dev/null +++ b/src/plesna_gerance/services/tresorerie.py @@ -0,0 +1,97 @@ +"""Ce qui est réellement reversé au propriétaire, et son rapprochement. + +Le compte rendu de gérance se termine par un solde : ce que le gérant verse au +propriétaire pour la période (créditeur) ou lui réclame (débiteur). Ce montant +est extrait du PDF, il ne se recalcule pas. + +Il se **retrouve** en revanche à partir des lignes, et c'est tout l'intérêt : + + net reversé = encaissé − débit + crédit + +L'encaissé, pas le facturé : un loyer appelé mais impayé n'est pas versé. Le +crédit des opérations, souvent nul, porte les régularisations en faveur du +propriétaire ; l'oublier décale le rapprochement d'autant. + +Sur la base réelle, cette égalité tombe au centime pour avril, mai et juin 2026, +et s'écarte pour février et mars — les deux mois dont l'extraction a par ailleurs +des défauts. L'écart entre le net recalculé et le solde annoncé est donc affiché, +jamais lissé : c'est le seul contrôle de bout en bout dont on dispose sur la +qualité d'une extraction, et un écart qui apparaît est une anomalie à instruire. +""" + +from datetime import date +from unicodedata import combining, normalize + +from sqlalchemy import func, select + +from ..database.models import Depense, Document + +#: Un solde créditeur est versé au propriétaire, un solde débiteur lui est +#: réclamé. La base stocke les deux formes « créditeur » et « crediteur » selon +#: que le parser a lu le PDF ou est retombé sur sa valeur par défaut. +SOLDE_CREDITEUR = "crediteur" + + +def _sans_accents(valeur: str) -> str: + return "".join(c for c in normalize("NFD", valeur) if not combining(c)) + + +def est_crediteur(solde_type: str | None) -> bool: + """Le solde est-il en faveur du propriétaire ?""" + if not solde_type: + return False + return _sans_accents(solde_type).lower() == SOLDE_CREDITEUR + + +def solde_signe(montant: float | None, solde_type: str | None) -> float: + """Solde d'un compte rendu, compté positivement quand il est versé.""" + montant = montant or 0.0 + return montant if est_crediteur(solde_type) else -montant + + +def _borner(stmt, date_debut: date | None, date_fin: date | None): + 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) + return stmt + + +def depenses_par(cle, date_debut: date | None = None, date_fin: date | None = None): + """Sous-requête : débit et crédit des opérations, regroupés par `cle`. + + Les dépenses sont des flux, elles se cumulent sans précaution particulière — + contrairement aux revenus, aucune ligne ne reporte le mois précédent. + """ + stmt = ( + select( + cle.label("cle"), + func.sum(Depense.debit).label("debit"), + func.sum(Depense.credit).label("credit"), + ) + .join(Document, Depense.document_id == Document.id) + .group_by(cle) + ) + return _borner(stmt, date_debut, date_fin).subquery() + + +def somme_soldes_annonces( + session, date_debut: date | None = None, date_fin: date | None = None +) -> float: + """Somme signée des soldes annoncés par les comptes rendus de la période. + + Les soldes sont signés en Python plutôt qu'en SQL pour que `solde_signe` + reste la seule règle : reconnaître un créditeur en base demanderait d'y + redire quelles orthographes acceptées, et les deux versions divergeraient. + Le volume s'y prête, un compte rendu par immeuble et par mois. + """ + stmt = select(Document.solde_montant, Document.solde_type) + lignes = session.execute(_borner(stmt, date_debut, date_fin)).all() + return round(sum(solde_signe(montant, type_) for montant, type_ in lignes), 2) + + +def net_reverse( + encaisse: float | None, debit: float | None, credit: float | None +) -> float: + """Ce qui revient au propriétaire sur la période.""" + return round((encaisse or 0.0) - (debit or 0.0) + (credit or 0.0), 2) diff --git a/tests/test_dashboard_coherence.py b/tests/test_dashboard_coherence.py index 227f108..ddf00e0 100644 --- a/tests/test_dashboard_coherence.py +++ b/tests/test_dashboard_coherence.py @@ -4,31 +4,33 @@ Les deux ecrans calculaient leurs totaux chacun de leur cote, avec des regles differentes : l'accueil lisait le dernier compte rendu, la page Revenus cumulait tout. Ils affichaient donc deux montants d'impayes pour la meme notion. Ces tests verrouillent leur accord. + +L'accueil resume desormais une annee civile entiere. S'y ajoute donc ce que +cette bascule engage : la periode couverte suit les donnees et non l'horloge, et +le net reverse se rapproche du solde annonce par les comptes rendus. """ import copy -from datetime import date, timedelta +from datetime import date import pytest from plesna_gerance.database.service import DatabaseService -def _mois_glissant(recul: int) -> str: - jour = date.today().replace(day=15) - for _ in range(recul): - jour = (jour.replace(day=1) - timedelta(days=1)).replace(day=15) - return jour.isoformat() - - @pytest.fixture def deux_comptes_rendus(db_session, sample_data): - """Un impaye de 300 ne le mois dernier, reporte et non regle ce mois-ci.""" + """Un impaye de 300 ne en janvier, reporte et non regle en fevrier.""" service = DatabaseService(db_session) premier = copy.deepcopy(sample_data) premier["metadata"]["document"]["reference"] = "M1" - premier["metadata"]["document"]["date"] = _mois_glissant(1) + premier["metadata"]["document"]["date"] = "2024-01-15" + premier["metadata"]["solde"] = { + "montant": 450.0, + "type": "crediteur", + "date_arrete": "2024-01-15", + } premier["situation_locataires"][0]["lignes"] = [ { "type": "loyer", @@ -43,7 +45,12 @@ def deux_comptes_rendus(db_session, sample_data): second = copy.deepcopy(sample_data) second["metadata"]["document"]["reference"] = "M2" - second["metadata"]["document"]["date"] = _mois_glissant(0) + second["metadata"]["document"]["date"] = "2024-02-15" + second["metadata"]["solde"] = { + "montant": 750.0, + "type": "crediteur", + "date_arrete": "2024-02-15", + } second["situation_locataires"][0]["lignes"] = [ { "type": "solde_anterieur", @@ -66,36 +73,189 @@ def deux_comptes_rendus(db_session, sample_data): return db_session -def test_les_deux_ecrans_annoncent_le_meme_impaye(api_client, deux_comptes_rendus): - """300 dus, vus depuis l'accueil comme depuis la page Revenus.""" - accueil = api_client.get("/api/dashboard/financial-summary").json() +def _resume(api_client, **params): + return api_client.get("/api/dashboard/resume-annuel", params=params).json() + + +def test_les_deux_ecrans_annoncent_le_meme_impaye(api_client, db_session, sample_data): + """300 dus, vus depuis l'accueil comme depuis la page Revenus. + + Le compte rendu est date d'aujourd'hui a dessein : les deux ecrans ne + cadrent plus la meme periode — annee civile ici, douze mois glissants + la-bas — et seule une donnee recente tombe dans les deux fenetres. Leur + accord porte sur la regle, pas sur le cadrage. + """ + document = copy.deepcopy(sample_data) + document["metadata"]["document"]["date"] = date.today().isoformat() + document["situation_locataires"][0]["lignes"] = [ + { + "type": "loyer", + "periode": {"debut": None, "fin": None}, + "loyers": 800.0, + "total": 800.0, + "regles": 500.0, + "impayes": 300.0, + } + ] + DatabaseService(db_session).save_document(data=document) + + accueil = _resume(api_client) revenus = api_client.get("/api/revenus/summary").json() - assert accueil["impayes"] == 300.0 + assert accueil["restant_du"] == 300.0 assert revenus["kpis"]["total_impayes"] == 300.0 -def test_le_revenu_du_dernier_compte_rendu_exclut_le_report( - api_client, deux_comptes_rendus -): - """Le mois vaut son loyer de 800, pas 1100 report compris.""" - accueil = api_client.get("/api/dashboard/financial-summary").json() +def test_le_facture_de_l_annee_exclut_le_report(api_client, deux_comptes_rendus): + """L'annee vaut ses deux loyers de 800, pas 1900 report compris.""" + assert _resume(api_client)["facture"] == 1600.0 - assert accueil["revenus"] == 800.0 + +def test_l_encaisse_retient_les_reglements_de_dettes_anciennes( + api_client, db_session, sample_data +): + """Un vieil impaye rattrape est un encaissement du mois, pas un report.""" + service = DatabaseService(db_session) + document = copy.deepcopy(sample_data) + document["metadata"]["document"]["date"] = "2024-03-15" + document["situation_locataires"][0]["lignes"] = [ + { + "type": "solde_anterieur", + "periode": {"debut": None, "fin": None}, + "loyers": 300.0, + "total": 300.0, + "regles": 300.0, + "impayes": 0.0, + } + ] + service.save_document(data=document) + + resume = _resume(api_client) + + assert resume["facture"] == 0.0 + assert resume["encaisse"] == 300.0 + + +def test_la_tendance_mensuelle_reste_comparable(api_client, deux_comptes_rendus): + """Chaque mois pese son loyer, sinon le second parait meilleur qu'il n'est.""" + par_mois = _resume(api_client)["par_mois"] + + assert [point["mois"] for point in par_mois] == ["2024-01", "2024-02"] + assert [point["facture"] for point in par_mois] == [800.0, 800.0] def test_les_raccourcis_immeubles_ne_cumulent_pas_la_dette( api_client, deux_comptes_rendus ): """Le raccourci montre la dette en cours, pas sa somme mois apres mois.""" - (raccourci,) = api_client.get("/api/dashboard/immeubles-shortcuts").json() + (raccourci,) = api_client.get( + "/api/dashboard/immeubles-shortcuts", params={"annee": 2024} + ).json() assert raccourci["total_impayes"] == 300.0 assert raccourci["total_revenus"] == 1600.0 # 800 + 800, report exclu -def test_la_tendance_mensuelle_reste_comparable(api_client, deux_comptes_rendus): - """Chaque mois pese son loyer, sinon le second parait meilleur qu'il n'est.""" - tendances = api_client.get("/api/dashboard/monthly-trends").json() +def test_le_net_reverse_se_rapproche_du_solde_annonce(api_client, deux_comptes_rendus): + """Encaisse moins depenses doit retomber sur les soldes des comptes rendus. - assert [point["revenus"] for point in tendances] == [800.0, 800.0] + C'est le seul controle de bout en bout sur la qualite d'une extraction : + 1300 encaisses, 100 de depenses sur les deux documents, contre 450 + 750 + annonces. + """ + resume = _resume(api_client) + + assert resume["encaisse"] == 1300.0 + assert resume["depenses_debit"] == 100.0 + assert resume["net_reverse"] == 1200.0 + assert resume["soldes_annonces"] == 1200.0 + assert resume["ecart_soldes"] == 0.0 + + +def test_un_ecart_de_solde_est_annonce_et_non_lisse( + api_client, db_session, sample_data +): + """Quand le recalcul et le solde du PDF divergent, l'ecart doit se voir.""" + document = copy.deepcopy(sample_data) + document["metadata"]["document"]["date"] = "2024-01-15" + document["metadata"]["solde"] = { + "montant": 1000.0, + "type": "crediteur", + "date_arrete": "2024-01-15", + } + DatabaseService(db_session).save_document(data=document) + + resume = _resume(api_client) + + # 500 encaisses - 50 de depenses = 450, la ou le document annonce 1000. + assert resume["net_reverse"] == 450.0 + assert resume["soldes_annonces"] == 1000.0 + assert resume["ecart_soldes"] == -550.0 + + +def test_un_solde_debiteur_se_soustrait(api_client, db_session, sample_data): + """Un mois debiteur est reclame au proprietaire, il ne s'ajoute pas.""" + document = copy.deepcopy(sample_data) + document["metadata"]["document"]["date"] = "2024-01-15" + document["metadata"]["solde"] = { + "montant": 200.0, + "type": "débiteur", + "date_arrete": "2024-01-15", + } + DatabaseService(db_session).save_document(data=document) + + assert _resume(api_client)["soldes_annonces"] == -200.0 + + +def test_l_annee_par_defaut_suit_les_donnees_pas_l_horloge( + api_client, deux_comptes_rendus +): + """Sans import recent, l'accueil montre la derniere annee pleine, pas du vide.""" + resume = _resume(api_client) + + assert resume["annee"] == 2024 + assert resume["annee"] != date.today().year + assert resume["annees_disponibles"] == [2024] + assert resume["perimetre"]["nb_comptes_rendus"] == 2 + + +def test_une_annee_sans_compte_rendu_reste_lisible(api_client, deux_comptes_rendus): + """Choisir une annee vide donne des zeros, pas une erreur.""" + resume = _resume(api_client, annee=2023) + + assert resume["annee"] == 2023 + assert resume["perimetre"]["nb_comptes_rendus"] == 0 + assert resume["facture"] == 0.0 + assert resume["restant_du"] == 0.0 + assert resume["par_mois"] == [] + + +def test_les_annees_ne_se_melangent_pas(api_client, db_session, sample_data): + """Le total d'une annee ne doit rien emprunter a la suivante.""" + service = DatabaseService(db_session) + for reference, jour in (("A", "2024-06-15"), ("B", "2025-06-15")): + document = copy.deepcopy(sample_data) + document["metadata"]["document"]["reference"] = reference + document["metadata"]["document"]["date"] = jour + service.save_document(data=document) + + assert _resume(api_client, annee=2024)["facture"] == 500.0 + assert _resume(api_client, annee=2025)["facture"] == 500.0 + assert _resume(api_client)["annees_disponibles"] == [2025, 2024] + + +def test_le_perimetre_ne_compte_que_les_lots_factures(api_client, deux_comptes_rendus): + """La table `lots` retient d'anciennes ecritures de numeros, sans revenu.""" + assert _resume(api_client)["perimetre"]["nb_lots_factures"] == 1 + + +def test_une_base_vide_ne_casse_pas_l_accueil(api_client): + """Premiere ouverture de l'application : aucun document, aucune erreur.""" + resume = _resume(api_client) + + assert resume["annees_disponibles"] == [] + assert resume["annee"] == date.today().year + assert resume["facture"] == 0.0 + assert resume["taux_recouvrement"] == 100.0 + assert resume["par_mois"] == [] + assert api_client.get("/api/dashboard/immeubles-shortcuts").json() == []