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 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 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 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 article: None,
488 delai_legal_jours: None,
489 });
490 }
491
492 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 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 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}