Skip to main content

koprogo_api/infrastructure/database/repositories/
stats_repository_impl.rs

1use crate::application::dto::{
2    AdminDashboardStats, DuAupresDuneAcp, NextMeetingInfo, SeedDataStats, SyndicDashboardStats,
3    UrgentTask,
4};
5use crate::application::error::AppError;
6use crate::application::ports::StatsRepository;
7use crate::infrastructure::pool::DbPool;
8use async_trait::async_trait;
9use chrono::Utc;
10use rust_decimal::Decimal;
11use sqlx::Row;
12use uuid::Uuid;
13
14pub struct PostgresStatsRepository {
15    pool: DbPool,
16}
17
18impl PostgresStatsRepository {
19    pub fn new(pool: DbPool) -> Self {
20        Self { pool }
21    }
22}
23
24#[async_trait]
25impl StatsRepository for PostgresStatsRepository {
26    async fn get_admin_dashboard_stats(&self) -> Result<AdminDashboardStats, AppError> {
27        let total_organizations =
28            sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM organizations")
29                .fetch_one(&self.pool)
30                .await
31                .map_err(|e| e.to_string())?;
32
33        let total_users = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
34            .fetch_one(&self.pool)
35            .await
36            .map_err(|e| e.to_string())?;
37
38        let total_buildings = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM buildings")
39            .fetch_one(&self.pool)
40            .await
41            .map_err(|e| e.to_string())?;
42
43        let active_subscriptions = sqlx::query_scalar::<_, i64>(
44            "SELECT COUNT(*) FROM organizations WHERE is_active = true",
45        )
46        .fetch_one(&self.pool)
47        .await
48        .map_err(|e| e.to_string())?;
49
50        let total_owners = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owners")
51            .fetch_one(&self.pool)
52            .await
53            .map_err(|e| e.to_string())?;
54
55        let total_units = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM units")
56            .fetch_one(&self.pool)
57            .await
58            .map_err(|e| e.to_string())?;
59
60        let total_expenses = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM expenses")
61            .fetch_one(&self.pool)
62            .await
63            .map_err(|e| e.to_string())?;
64
65        let total_meetings = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM meetings")
66            .fetch_one(&self.pool)
67            .await
68            .map_err(|e| e.to_string())?;
69
70        Ok(AdminDashboardStats {
71            total_organizations,
72            total_users,
73            total_buildings,
74            active_subscriptions,
75            total_owners,
76            total_units,
77            total_expenses,
78            total_meetings,
79        })
80    }
81
82    async fn get_seed_data_stats(&self) -> Result<SeedDataStats, AppError> {
83        let seed_organizations = sqlx::query_scalar::<_, i64>(
84            "SELECT COUNT(*) FROM organizations WHERE is_seed_data = true",
85        )
86        .fetch_one(&self.pool)
87        .await
88        .map_err(|e| e.to_string())?;
89
90        let production_organizations = sqlx::query_scalar::<_, i64>(
91            "SELECT COUNT(*) FROM organizations WHERE is_seed_data = false",
92        )
93        .fetch_one(&self.pool)
94        .await
95        .map_err(|e| e.to_string())?;
96
97        let seed_buildings = sqlx::query_scalar::<_, i64>(
98            "SELECT COUNT(*) FROM buildings b
99             INNER JOIN acps a ON a.id = b.acp_id
100             INNER JOIN organizations o ON a.organization_id = o.id
101             WHERE o.is_seed_data = true",
102        )
103        .fetch_one(&self.pool)
104        .await
105        .map_err(|e| e.to_string())?;
106
107        let seed_units = sqlx::query_scalar::<_, i64>(
108            "SELECT COUNT(*) FROM units u
109             INNER JOIN buildings b ON u.building_id = b.id
110             INNER JOIN acps a ON a.id = b.acp_id
111             INNER JOIN organizations o ON a.organization_id = o.id
112             WHERE o.is_seed_data = true",
113        )
114        .fetch_one(&self.pool)
115        .await
116        .map_err(|e| e.to_string())?;
117
118        let seed_owners = sqlx::query_scalar::<_, i64>(
119            "SELECT COUNT(DISTINCT o.id) FROM owners o
120             INNER JOIN unit_owners uo ON o.id = uo.owner_id
121             INNER JOIN units u ON uo.unit_id = u.id
122             INNER JOIN buildings b ON u.building_id = b.id
123             INNER JOIN acps a ON a.id = b.acp_id
124             INNER JOIN organizations org ON a.organization_id = org.id
125             WHERE org.is_seed_data = true",
126        )
127        .fetch_one(&self.pool)
128        .await
129        .map_err(|e| e.to_string())?;
130
131        let seed_unit_owners = sqlx::query_scalar::<_, i64>(
132            "SELECT COUNT(*) FROM unit_owners uo
133             INNER JOIN units u ON uo.unit_id = u.id
134             INNER JOIN buildings b ON u.building_id = b.id
135             INNER JOIN acps a ON a.id = b.acp_id
136             INNER JOIN organizations o ON a.organization_id = o.id
137             WHERE o.is_seed_data = true",
138        )
139        .fetch_one(&self.pool)
140        .await
141        .map_err(|e| e.to_string())?;
142
143        let seed_expenses = sqlx::query_scalar::<_, i64>(
144            "SELECT COUNT(*) FROM expenses e
145             INNER JOIN buildings b ON e.building_id = b.id
146             INNER JOIN acps a ON a.id = b.acp_id
147             INNER JOIN organizations o ON a.organization_id = o.id
148             WHERE o.is_seed_data = true",
149        )
150        .fetch_one(&self.pool)
151        .await
152        .map_err(|e| e.to_string())?;
153
154        let seed_meetings = sqlx::query_scalar::<_, i64>(
155            "SELECT COUNT(*) FROM meetings m
156             INNER JOIN buildings b ON m.building_id = b.id
157             INNER JOIN acps a ON a.id = b.acp_id
158             INNER JOIN organizations o ON a.organization_id = o.id
159             WHERE o.is_seed_data = true",
160        )
161        .fetch_one(&self.pool)
162        .await
163        .map_err(|e| e.to_string())?;
164
165        let seed_users = sqlx::query_scalar::<_, i64>(
166            "SELECT COUNT(*) FROM users u
167             INNER JOIN organizations o ON u.organization_id = o.id
168             WHERE o.is_seed_data = true",
169        )
170        .fetch_one(&self.pool)
171        .await
172        .map_err(|e| e.to_string())?;
173
174        Ok(SeedDataStats {
175            seed_organizations,
176            production_organizations,
177            seed_buildings,
178            seed_units,
179            seed_owners,
180            seed_unit_owners,
181            seed_expenses,
182            seed_meetings,
183            seed_users,
184        })
185    }
186
187    async fn get_syndic_stats(
188        &self,
189        organization_id: Uuid,
190    ) -> Result<SyndicDashboardStats, AppError> {
191        let total_buildings = sqlx::query_scalar::<_, i64>(
192            "SELECT COUNT(*) FROM buildings b JOIN acps a ON a.id = b.acp_id WHERE a.organization_id = $1",
193        )
194        .bind(organization_id)
195        .fetch_one(&self.pool)
196        .await
197        .map_err(|e| e.to_string())?;
198
199        let total_units = sqlx::query_scalar::<_, i64>(
200            "SELECT COUNT(*) FROM units u
201             INNER JOIN buildings b ON u.building_id = b.id
202             WHERE b.acp_id IN (SELECT id FROM acps WHERE organization_id = $1)",
203        )
204        .bind(organization_id)
205        .fetch_one(&self.pool)
206        .await
207        .map_err(|e| e.to_string())?;
208
209        // COALESCE : SUM sur un ensemble vide rend NULL, pas 0.
210        let declared_units = sqlx::query_scalar::<_, i64>(
211            "SELECT COALESCE(SUM(b.total_units), 0)::bigint FROM buildings b
212             WHERE b.acp_id IN (SELECT id FROM acps WHERE organization_id = $1)",
213        )
214        .bind(organization_id)
215        .fetch_one(&self.pool)
216        .await
217        .map_err(|e| e.to_string())?;
218
219        let total_owners = sqlx::query_scalar::<_, i64>(
220            "SELECT COUNT(DISTINCT o.id) FROM owners o
221             INNER JOIN unit_owners uo ON o.id = uo.owner_id
222             INNER JOIN units u ON uo.unit_id = u.id
223             INNER JOIN buildings b ON u.building_id = b.id
224             WHERE b.acp_id IN (SELECT id FROM acps WHERE organization_id = $1) AND uo.end_date IS NULL",
225        )
226        .bind(organization_id)
227        .fetch_one(&self.pool)
228        .await
229        .map_err(|e| e.to_string())?;
230
231        let row = sqlx::query(
232            "SELECT COUNT(*) as count, COALESCE(SUM(amount), 0::NUMERIC) as total
233             FROM expenses e
234             INNER JOIN buildings b ON e.building_id = b.id
235             WHERE b.acp_id IN (SELECT id FROM acps WHERE organization_id = $1) AND e.payment_status = 'pending'",
236        )
237        .bind(organization_id)
238        .fetch_one(&self.pool)
239        .await
240        .map_err(|e| e.to_string())?;
241        let pending_count: i64 = row.try_get("count").unwrap_or(0);
242        let pending_total: Decimal = row.try_get("total").unwrap_or(Decimal::ZERO);
243
244        let next_meeting_row = sqlx::query(
245            "SELECT m.id, m.scheduled_date, b.name as building_name
246             FROM meetings m
247             INNER JOIN buildings b ON m.building_id = b.id
248             WHERE b.acp_id IN (SELECT id FROM acps WHERE organization_id = $1) AND m.scheduled_date > NOW() AND m.status = 'scheduled'
249             ORDER BY m.scheduled_date ASC
250             LIMIT 1",
251        )
252        .bind(organization_id)
253        .fetch_optional(&self.pool)
254        .await
255        .map_err(|e| e.to_string())?;
256
257        Ok(SyndicDashboardStats {
258            total_buildings,
259            total_units,
260            declared_units,
261            total_owners,
262            pending_expenses_count: pending_count,
263            pending_expenses_amount: pending_total,
264            next_meeting: next_meeting_row.map(|m| NextMeetingInfo {
265                id: m.get::<Uuid, _>("id").to_string(),
266                date: m.get("scheduled_date"),
267                building_name: m.get("building_name"),
268            }),
269        })
270    }
271
272    async fn get_owner_stats(&self, owner_id: Uuid) -> Result<SyndicDashboardStats, AppError> {
273        let total_buildings = sqlx::query_scalar::<_, i64>(
274            "SELECT COUNT(DISTINCT b.id) FROM buildings b
275             INNER JOIN units u ON b.id = u.building_id
276             INNER JOIN unit_owners uo ON u.id = uo.unit_id
277             WHERE uo.owner_id = $1 AND uo.end_date IS NULL",
278        )
279        .bind(owner_id)
280        .fetch_one(&self.pool)
281        .await
282        .map_err(|e| e.to_string())?;
283
284        let total_units = sqlx::query_scalar::<_, i64>(
285            "SELECT COUNT(*) FROM unit_owners uo WHERE uo.owner_id = $1 AND uo.end_date IS NULL",
286        )
287        .bind(owner_id)
288        .fetch_one(&self.pool)
289        .await
290        .map_err(|e| e.to_string())?;
291
292        let total_owners = sqlx::query_scalar::<_, i64>(
293            "SELECT COUNT(DISTINCT uo2.owner_id) FROM unit_owners uo2
294             INNER JOIN units u ON uo2.unit_id = u.id
295             WHERE u.building_id IN (
296                 SELECT DISTINCT u2.building_id FROM units u2
297                 INNER JOIN unit_owners uo ON u2.id = uo.unit_id
298                 WHERE uo.owner_id = $1 AND uo.end_date IS NULL
299             ) AND uo2.end_date IS NULL",
300        )
301        .bind(owner_id)
302        .fetch_one(&self.pool)
303        .await
304        .map_err(|e| e.to_string())?;
305
306        let row = sqlx::query(
307            "SELECT COUNT(*) as count, COALESCE(SUM(amount), 0::NUMERIC) as total
308             FROM expenses e
309             WHERE e.building_id IN (
310                 SELECT DISTINCT u.building_id FROM units u
311                 INNER JOIN unit_owners uo ON u.id = uo.unit_id
312                 WHERE uo.owner_id = $1 AND uo.end_date IS NULL
313             ) AND e.payment_status = 'pending'",
314        )
315        .bind(owner_id)
316        .fetch_one(&self.pool)
317        .await
318        .map_err(|e| e.to_string())?;
319        let pending_count: i64 = row.try_get("count").unwrap_or(0);
320        let pending_total: Decimal = row.try_get("total").unwrap_or(Decimal::ZERO);
321
322        let next_meeting_row = sqlx::query(
323            "SELECT m.id, m.scheduled_date, b.name as building_name
324             FROM meetings m
325             INNER JOIN buildings b ON m.building_id = b.id
326             WHERE b.id IN (
327                 SELECT DISTINCT u.building_id FROM units u
328                 INNER JOIN unit_owners uo ON u.id = uo.unit_id
329                 WHERE uo.owner_id = $1 AND uo.end_date IS NULL
330             )
331             AND m.scheduled_date > NOW() AND m.status = 'scheduled'
332             ORDER BY m.scheduled_date ASC
333             LIMIT 1",
334        )
335        .bind(owner_id)
336        .fetch_optional(&self.pool)
337        .await
338        .map_err(|e| e.to_string())?;
339
340        Ok(SyndicDashboardStats {
341            total_buildings,
342            total_units,
343            declared_units: total_units,
344            total_owners,
345            pending_expenses_count: pending_count,
346            pending_expenses_amount: pending_total,
347            next_meeting: next_meeting_row.map(|m| NextMeetingInfo {
348                id: m.get::<Uuid, _>("id").to_string(),
349                date: m.get("scheduled_date"),
350                building_name: m.get("building_name"),
351            }),
352        })
353    }
354
355    async fn find_owner_id_by_user_id(&self, user_id: Uuid) -> Result<Option<Uuid>, AppError> {
356        let row = sqlx::query("SELECT id FROM owners WHERE user_id = $1")
357            .bind(user_id)
358            .fetch_optional(&self.pool)
359            .await
360            .map_err(|e| e.to_string())?;
361        Ok(row.map(|r| r.get("id")))
362    }
363
364    async fn get_owner_dues_by_acp(
365        &self,
366        owner_id: Uuid,
367    ) -> Result<Vec<DuAupresDuneAcp>, AppError> {
368        // Le groupement se fait sur l'ACP, pas sur l'immeuble : une ACP peut
369        // compter plusieurs blocs, et c'est ELLE qui a le compte bancaire.
370        //
371        // `DISTINCT u.building_id` dans la sous-requête : sans lui, un
372        // copropriétaire détenant deux lots dans le même immeuble compterait
373        // ses charges deux fois.
374        let lignes = sqlx::query(
375            r#"
376            SELECT
377                a.id                                          AS acp_id,
378                a.name                                        AS acp_name,
379                a.bce_number                                  AS bce_number,
380                COUNT(e.id)                                   AS charges_en_attente,
381                COALESCE(SUM(e.amount), 0::NUMERIC)           AS montant
382            FROM acps a
383            INNER JOIN buildings b ON b.acp_id = a.id
384            INNER JOIN expenses e  ON e.building_id = b.id
385            WHERE e.payment_status = 'pending'
386              AND b.id IN (
387                  SELECT DISTINCT u.building_id
388                  FROM units u
389                  INNER JOIN unit_owners uo ON uo.unit_id = u.id
390                  WHERE uo.owner_id = $1 AND uo.end_date IS NULL
391              )
392            GROUP BY a.id, a.name, a.bce_number
393            HAVING COALESCE(SUM(e.amount), 0::NUMERIC) > 0
394            ORDER BY a.name
395            "#,
396        )
397        .bind(owner_id)
398        .fetch_all(&self.pool)
399        .await
400        .map_err(|e| AppError::Database(e.to_string()))?;
401
402        Ok(lignes
403            .into_iter()
404            .map(|ligne| DuAupresDuneAcp {
405                acp_id: ligne.get::<Uuid, _>("acp_id").to_string(),
406                acp_name: ligne.get("acp_name"),
407                bce_number: ligne.try_get("bce_number").unwrap_or(None),
408                charges_en_attente: ligne.try_get("charges_en_attente").unwrap_or(0),
409                montant: ligne
410                    .try_get("montant")
411                    .unwrap_or(rust_decimal::Decimal::ZERO),
412            })
413            .collect())
414    }
415
416    async fn get_syndic_urgent_tasks(
417        &self,
418        organization_id: Uuid,
419    ) -> Result<Vec<UrgentTask>, AppError> {
420        let mut tasks: Vec<UrgentTask> = Vec::new();
421
422        let overdue_expenses = sqlx::query(
423            "SELECT e.id, e.description, e.amount, b.name as building_name, e.expense_date
424             FROM expenses e
425             INNER JOIN buildings b ON e.building_id = b.id
426             WHERE b.acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
427             AND e.payment_status = 'overdue'
428             ORDER BY e.expense_date ASC
429             LIMIT 5",
430        )
431        .bind(organization_id)
432        .fetch_all(&self.pool)
433        .await
434        .map_err(|e| e.to_string())?;
435
436        for expense in overdue_expenses {
437            let amount: rust_decimal::Decimal = expense.try_get("amount")?;
438            let id: Uuid = expense.get("id");
439            tasks.push(UrgentTask {
440                task_type: "expense".to_string(),
441                title: format!("Charge en retard - {}€", amount.round_dp(2)),
442                description: expense.get("description"),
443                priority: "urgent".to_string(),
444                building_name: Some(expense.get("building_name")),
445                entity_id: Some(id.to_string()),
446                due_date: Some(expense.get("expense_date")),
447                // Un retard de paiement est contractuel, pas légal : aucun
448                // article ne fixe d'échéance ici, et prétendre le contraire
449                // afficherait un décompte sans fondement.
450                article: None,
451                delai_legal_jours: None,
452            });
453        }
454
455        let upcoming_meetings = sqlx::query(
456            "SELECT m.id, m.title, m.scheduled_date, b.name as building_name
457             FROM meetings m
458             INNER JOIN buildings b ON m.building_id = b.id
459             WHERE b.acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
460             AND m.status = 'scheduled'
461             AND m.scheduled_date BETWEEN NOW() AND NOW() + INTERVAL '7 days'
462             ORDER BY m.scheduled_date ASC
463             LIMIT 3",
464        )
465        .bind(organization_id)
466        .fetch_all(&self.pool)
467        .await
468        .map_err(|e| e.to_string())?;
469
470        for meeting in upcoming_meetings {
471            let scheduled_date: chrono::DateTime<Utc> = meeting.get("scheduled_date");
472            let days_until = (scheduled_date - Utc::now()).num_days();
473            let priority = if days_until <= 3 { "urgent" } else { "high" };
474            let id: Uuid = meeting.get("id");
475            tasks.push(UrgentTask {
476                task_type: "meeting".to_string(),
477                title: meeting.get("title"),
478                description: format!("AG dans {} jours", days_until),
479                priority: priority.to_string(),
480                building_name: Some(meeting.get("building_name")),
481                entity_id: Some(id.to_string()),
482                due_date: Some(scheduled_date),
483                // Une assemblée à venir est un rendez-vous, pas une échéance
484                // légale. Le délai de convocation de l'Art. 3.87 § 3, lui, en
485                // est une — mais il porte sur la convocation, pas sur la
486                // tenue.
487                article: None,
488                delai_legal_jours: None,
489            });
490        }
491
492        // ── Procès-verbaux à transmettre — Art. 3.87 § 12 CC ─────────────
493        //
494        // Le PV est consigné au registre et transmis à chaque destinataire
495        // **dans les trente jours** de l'assemblée. C'est la seule des tâches
496        // de ce tableau de bord qui porte une échéance LÉGALE, et rien ne la
497        // suivait : les colonnes `minutes_document_id` et `minutes_sent_at`
498        // existent depuis la migration du 2026-03-23, dont le commentaire
499        // annonce « Track when AG minutes are sent to owners (within 30
500        // days) ». Personne ne les lisait.
501        //
502        // Une capacité écrite, migrée, et inatteignable — le motif dominant de
503        // ce périmètre.
504        let pv_en_attente = sqlx::query(
505            "SELECT m.id, m.title, m.scheduled_date, b.name as building_name
506             FROM meetings m
507             INNER JOIN buildings b ON m.building_id = b.id
508             WHERE b.acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
509             AND m.status = 'completed'
510             AND m.minutes_sent_at IS NULL
511             AND m.scheduled_date > NOW() - INTERVAL '90 days'
512             ORDER BY m.scheduled_date ASC
513             LIMIT 5",
514        )
515        .bind(organization_id)
516        .fetch_all(&self.pool)
517        .await
518        .map_err(|e| e.to_string())?;
519
520        for reunion in pv_en_attente {
521            let tenue_le: chrono::DateTime<Utc> = reunion.get("scheduled_date");
522            let delai = crate::domain::copropriete::consignation_pv::DELAI_JOURS;
523            let echeance = tenue_le + chrono::Duration::days(delai);
524            let jours_restants = (echeance - Utc::now()).num_days();
525            let id: Uuid = reunion.get("id");
526            let titre: String = reunion.get("title");
527
528            tasks.push(UrgentTask {
529                task_type: "minutes".to_string(),
530                title: titre,
531                description: if jours_restants < 0 {
532                    format!("PV non transmis, {} jours de retard", -jours_restants)
533                } else {
534                    format!("PV à transmettre sous {jours_restants} jours")
535                },
536                // Dépassé, c'est un manquement constaté, pas une urgence à
537                // venir : la distinction change ce que le syndic doit faire.
538                priority: if jours_restants < 0 { "urgent" } else { "high" }.to_string(),
539                building_name: Some(reunion.get("building_name")),
540                entity_id: Some(id.to_string()),
541                due_date: Some(echeance),
542                article: Some("Art. 3.87 § 12 CC".to_string()),
543                // Le délai est LU depuis le domaine, jamais recopié.
544                delai_legal_jours: Some(delai),
545            });
546        }
547
548        let pending_overdue_count = sqlx::query_scalar::<_, i64>(
549            "SELECT COUNT(*)
550             FROM expenses e
551             INNER JOIN buildings b ON e.building_id = b.id
552             WHERE b.acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
553             AND e.payment_status = 'pending'
554             AND e.expense_date < NOW() - INTERVAL '30 days'",
555        )
556        .bind(organization_id)
557        .fetch_one(&self.pool)
558        .await
559        .map_err(|e| e.to_string())?;
560
561        if pending_overdue_count > 0 {
562            tasks.push(UrgentTask {
563                task_type: "expense".to_string(),
564                title: "Relance paiements".to_string(),
565                description: format!(
566                    "{} charges en attente depuis plus de 30 jours",
567                    pending_overdue_count
568                ),
569                priority: "high".to_string(),
570                building_name: None,
571                entity_id: None,
572                due_date: None,
573                article: None,
574                delai_legal_jours: None,
575            });
576        }
577
578        tasks.sort_by(|a, b| {
579            let priority_order = |p: &str| match p {
580                "urgent" => 0,
581                "high" => 1,
582                _ => 2,
583            };
584            priority_order(&a.priority).cmp(&priority_order(&b.priority))
585        });
586
587        Ok(tasks)
588    }
589}