Skip to main content

koprogo_api/application/use_cases/
building_use_cases.rs

1use crate::application::dto::{
2    BuildingFilters, BuildingResponseDto, CreateBuildingDto, PageRequest, UpdateBuildingDto,
3};
4use crate::application::error::AppError;
5use crate::application::ports::BuildingRepository;
6use crate::domain::entities::{Building, BuildingMetrics};
7use std::sync::Arc;
8use uuid::Uuid;
9
10pub struct BuildingUseCases {
11    repository: Arc<dyn BuildingRepository>,
12}
13
14impl BuildingUseCases {
15    pub fn new(repository: Arc<dyn BuildingRepository>) -> Self {
16        Self { repository }
17    }
18
19    pub async fn create_building(
20        &self,
21        dto: CreateBuildingDto,
22    ) -> Result<BuildingResponseDto, String> {
23        // Story 1.2 — Building::acp_id (FK vers acps.id, anciennement
24        // organization_id). Le DTO expose désormais `acp_id` (renommé) ;
25        // le handler résout l'ACP à partir de l'organisation du JWT pour
26        // les non-superadmins (cf. building_handlers::create_building).
27        let acp_id =
28            Uuid::parse_str(&dto.acp_id).map_err(|_| "Invalid acp_id format".to_string())?;
29
30        let building = Building::new(
31            acp_id,
32            dto.name,
33            dto.address,
34            dto.city,
35            dto.postal_code,
36            dto.country,
37            dto.total_units,
38            dto.total_tantiemes.unwrap_or(1000),
39            dto.construction_year,
40        )?;
41
42        let created = self.repository.create(&building).await?;
43        Ok(self.to_response_dto(&created))
44    }
45
46    pub async fn get_building(&self, id: Uuid) -> Result<Option<BuildingResponseDto>, String> {
47        let building = self.repository.find_by_id(id).await?;
48        Ok(building.map(|b| self.to_response_dto(&b)))
49    }
50
51    /// Story 1.4 — Get building + metrics (count units + SUM quotas) +
52    /// is_conformant + delta. Retour typé `AppError` (cluster #555).
53    ///
54    /// `Ok(None)` quand l'id n'existe pas (le handler le mappe en 404).
55    /// Toute erreur infra remonte en `AppError::Internal` via `From<String>`.
56    pub async fn get_building_with_metrics(
57        &self,
58        id: Uuid,
59    ) -> Result<Option<BuildingResponseDto>, AppError> {
60        let pair = self
61            .repository
62            .find_by_id_with_metrics(id)
63            .await
64            .map_err(AppError::from)?;
65        Ok(pair.map(|(b, m)| Self::to_response_dto_with_metrics(&b, &m)))
66    }
67
68    pub async fn list_buildings(&self) -> Result<Vec<BuildingResponseDto>, String> {
69        let buildings = self.repository.find_all().await?;
70        Ok(buildings.iter().map(|b| self.to_response_dto(b)).collect())
71    }
72
73    pub async fn list_buildings_paginated(
74        &self,
75        page_request: &PageRequest,
76        organization_id: Option<Uuid>,
77    ) -> Result<(Vec<BuildingResponseDto>, i64), String> {
78        let filters = BuildingFilters {
79            organization_id,
80            ..Default::default()
81        };
82
83        let (buildings, total) = self
84            .repository
85            .find_all_paginated(page_request, &filters)
86            .await?;
87
88        let dtos = buildings.iter().map(|b| self.to_response_dto(b)).collect();
89        Ok((dtos, total))
90    }
91
92    /// Liste paginée avec filtrage Owner (BUG-WF14-2)
93    /// Si owner_user_id est Some, filtre les buildings où le user possède un lot
94    pub async fn list_buildings_paginated_for_user(
95        &self,
96        page_request: &PageRequest,
97        organization_id: Option<Uuid>,
98        owner_user_id: Option<Uuid>,
99        search: Option<String>,
100    ) -> Result<(Vec<BuildingResponseDto>, i64), String> {
101        let filters = BuildingFilters {
102            organization_id,
103            owner_user_id,
104            search,
105            ..Default::default()
106        };
107
108        let (buildings, total) = self
109            .repository
110            .find_all_paginated(page_request, &filters)
111            .await?;
112
113        let dtos = buildings.iter().map(|b| self.to_response_dto(b)).collect();
114        Ok((dtos, total))
115    }
116
117    pub async fn update_building(
118        &self,
119        id: Uuid,
120        dto: UpdateBuildingDto,
121    ) -> Result<BuildingResponseDto, String> {
122        let mut building = self
123            .repository
124            .find_by_id(id)
125            .await?
126            .ok_or_else(|| "Building not found".to_string())?;
127
128        // Story 1.2 — Réaffectation ACP (SuperAdmin uniquement).
129        if let Some(acp_id_str) = dto.acp_id {
130            let acp_id =
131                Uuid::parse_str(&acp_id_str).map_err(|_| "Invalid acp_id format".to_string())?;
132            building.acp_id = acp_id;
133        }
134
135        building.update_info(
136            dto.name,
137            dto.address,
138            dto.city,
139            dto.postal_code,
140            dto.country,
141            dto.total_units,
142            dto.total_tantiemes.unwrap_or(1000),
143            dto.construction_year,
144        );
145
146        let updated = self.repository.update(&building).await?;
147        Ok(self.to_response_dto(&updated))
148    }
149
150    pub async fn delete_building(&self, id: Uuid) -> Result<bool, String> {
151        self.repository.delete(id).await
152    }
153
154    /// Find building by URL slug (for public pages - Issue #92)
155    pub async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String> {
156        self.repository.find_by_slug(slug).await
157    }
158
159    fn to_response_dto(&self, building: &Building) -> BuildingResponseDto {
160        // Story 1.4 : par défaut on retourne des métriques vides — les
161        // callers historiques (list, update) ne paient pas le coût d'un
162        // JOIN. Le path GET unique passe par `to_response_dto_with_metrics`
163        // pour exposer is_conformant + delta réels.
164        Self::to_response_dto_with_metrics(building, &BuildingMetrics::empty())
165    }
166
167    /// Story 1.4 — Variante exposant les métriques réelles (count units +
168    /// SUM quotas) + `is_conformant` + delta Decimal-as-string.
169    ///
170    /// Strict Decimal : `quota_sum`/`quota_delta` sérialisés via `to_string()`
171    /// (jamais `to_f64`) — cf. mémoire `no-f64-in-money` + ADR-0007.
172    fn to_response_dto_with_metrics(
173        building: &Building,
174        metrics: &BuildingMetrics,
175    ) -> BuildingResponseDto {
176        let is_conformant = building.is_conformant(metrics);
177        // Track H Story H1 — `quota_delta` est désormais méthode d'instance
178        // (acte de base lu sur `self.total_tantiemes`).
179        let delta = building.quota_delta(metrics);
180        BuildingResponseDto {
181            id: building.id.to_string(),
182            acp_id: building.acp_id.to_string(),
183            name: building.name.clone(),
184            address: building.address.clone(),
185            city: building.city.clone(),
186            postal_code: building.postal_code.clone(),
187            country: building.country.clone(),
188            total_units: building.total_units,
189            total_tantiemes: building.total_tantiemes,
190            construction_year: building.construction_year,
191            created_at: building.created_at.to_rfc3339(),
192            updated_at: building.updated_at.to_rfc3339(),
193            units_count: metrics.units_count,
194            quota_sum: metrics.quota_sum.to_string(),
195            is_conformant,
196            quota_delta: delta.to_string(),
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::application::ports::BuildingRepository;
205    use async_trait::async_trait;
206    use mockall::mock;
207
208    mock! {
209        BuildingRepo {}
210
211        #[async_trait]
212        impl BuildingRepository for BuildingRepo {
213            async fn create(&self, building: &Building) -> Result<Building, String>;
214            async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
215            async fn find_all(&self) -> Result<Vec<Building>, String>;
216            async fn find_all_paginated(
217                &self,
218                page_request: &PageRequest,
219                filters: &BuildingFilters,
220            ) -> Result<(Vec<Building>, i64), String>;
221            async fn update(&self, building: &Building) -> Result<Building, String>;
222            async fn delete(&self, id: Uuid) -> Result<bool, String>;
223            async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
224            async fn find_by_id_with_metrics(
225                &self,
226                id: Uuid,
227            ) -> Result<Option<(Building, BuildingMetrics)>, String>;
228        }
229    }
230
231    #[tokio::test]
232    async fn test_create_building_success() {
233        let mut mock_repo = MockBuildingRepo::new();
234
235        mock_repo.expect_create().returning(|b| Ok(b.clone()));
236
237        let use_cases = BuildingUseCases::new(Arc::new(mock_repo));
238
239        let dto = CreateBuildingDto {
240            acp_id: Uuid::new_v4().to_string(),
241            name: "Test Building".to_string(),
242            address: "123 Test St".to_string(),
243            city: "Paris".to_string(),
244            postal_code: "75001".to_string(),
245            country: "France".to_string(),
246            total_units: 10,
247            total_tantiemes: Some(1000),
248            construction_year: Some(2000),
249        };
250
251        let result = use_cases.create_building(dto).await;
252        assert!(result.is_ok());
253    }
254
255    #[tokio::test]
256    async fn test_create_building_validation_fails() {
257        let mock_repo = MockBuildingRepo::new();
258        let use_cases = BuildingUseCases::new(Arc::new(mock_repo));
259
260        let dto = CreateBuildingDto {
261            acp_id: Uuid::new_v4().to_string(),
262            name: "".to_string(), // Invalid: empty name
263            address: "123 Test St".to_string(),
264            city: "Paris".to_string(),
265            postal_code: "75001".to_string(),
266            country: "France".to_string(),
267            total_units: 10,
268            total_tantiemes: Some(1000),
269            construction_year: Some(2000),
270        };
271
272        let result = use_cases.create_building(dto).await;
273        assert!(result.is_err());
274    }
275}