+
-
+
-
-
-
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() == []