koprogo_api/domain/entities/
building.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Représente un immeuble en copropriété
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct Building {
8    pub id: Uuid,
9    pub organization_id: Uuid,
10    pub name: String,
11    pub address: String,
12    pub city: String,
13    pub postal_code: String,
14    pub country: String,
15    pub total_units: i32,
16    pub total_tantiemes: i32,
17    pub construction_year: Option<i32>,
18
19    // Public syndic information (Belgian legal requirement - Issue #92)
20    pub syndic_name: Option<String>,
21    pub syndic_email: Option<String>,
22    pub syndic_phone: Option<String>,
23    pub syndic_address: Option<String>,
24    pub syndic_office_hours: Option<String>,
25    pub syndic_emergency_contact: Option<String>,
26    pub slug: Option<String>,
27
28    pub created_at: DateTime<Utc>,
29    pub updated_at: DateTime<Utc>,
30}
31
32impl Building {
33    #[allow(clippy::too_many_arguments)]
34    pub fn new(
35        organization_id: Uuid,
36        name: String,
37        address: String,
38        city: String,
39        postal_code: String,
40        country: String,
41        total_units: i32,
42        total_tantiemes: i32,
43        construction_year: Option<i32>,
44    ) -> Result<Self, String> {
45        if name.is_empty() {
46            return Err("Building name cannot be empty".to_string());
47        }
48        if total_units <= 0 {
49            return Err("Total units must be greater than 0".to_string());
50        }
51        if total_tantiemes <= 0 {
52            return Err("Total tantiemes must be greater than 0".to_string());
53        }
54
55        let now = Utc::now();
56        let slug = Self::generate_slug(&name, &address, &city);
57
58        Ok(Self {
59            id: Uuid::new_v4(),
60            organization_id,
61            name,
62            address,
63            city,
64            postal_code,
65            country,
66            total_units,
67            total_tantiemes,
68            construction_year,
69            syndic_name: None,
70            syndic_email: None,
71            syndic_phone: None,
72            syndic_address: None,
73            syndic_office_hours: None,
74            syndic_emergency_contact: None,
75            slug: Some(slug),
76            created_at: now,
77            updated_at: now,
78        })
79    }
80
81    #[allow(clippy::too_many_arguments)]
82    pub fn update_info(
83        &mut self,
84        name: String,
85        address: String,
86        city: String,
87        postal_code: String,
88        country: String,
89        total_units: i32,
90        total_tantiemes: i32,
91        construction_year: Option<i32>,
92    ) {
93        self.name = name.clone();
94        self.address = address.clone();
95        self.city = city.clone();
96        self.postal_code = postal_code;
97        self.country = country;
98        self.total_units = total_units;
99        self.total_tantiemes = total_tantiemes;
100        self.construction_year = construction_year;
101
102        // Regenerate slug if name, address, or city changed
103        self.slug = Some(Self::generate_slug(&name, &address, &city));
104
105        self.updated_at = Utc::now();
106    }
107
108    /// Update syndic public information (Belgian legal requirement)
109    #[allow(clippy::too_many_arguments)]
110    pub fn update_syndic_info(
111        &mut self,
112        syndic_name: Option<String>,
113        syndic_email: Option<String>,
114        syndic_phone: Option<String>,
115        syndic_address: Option<String>,
116        syndic_office_hours: Option<String>,
117        syndic_emergency_contact: Option<String>,
118    ) {
119        self.syndic_name = syndic_name;
120        self.syndic_email = syndic_email;
121        self.syndic_phone = syndic_phone;
122        self.syndic_address = syndic_address;
123        self.syndic_office_hours = syndic_office_hours;
124        self.syndic_emergency_contact = syndic_emergency_contact;
125        self.updated_at = Utc::now();
126    }
127
128    /// Generate SEO-friendly slug from building name, address, and city
129    /// Example: "Residence Les Jardins, 123 Rue de la Paix, Paris" -> "residence-les-jardins-paris"
130    fn generate_slug(name: &str, _address: &str, city: &str) -> String {
131        let combined = format!("{} {}", name, city);
132
133        combined
134            .chars()
135            .map(|c| {
136                // Remove accents and special characters BEFORE lowercase
137                match c {
138                    'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'à' | 'á' | 'â' | 'ã' | 'ä' => 'a',
139                    'È' | 'É' | 'Ê' | 'Ë' | 'è' | 'é' | 'ê' | 'ë' => 'e',
140                    'Ì' | 'Í' | 'Î' | 'Ï' | 'ì' | 'í' | 'î' | 'ï' => 'i',
141                    'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o',
142                    'Ù' | 'Ú' | 'Û' | 'Ü' | 'ù' | 'ú' | 'û' | 'ü' => 'u',
143                    'Ç' | 'ç' => 'c',
144                    'Ñ' | 'ñ' => 'n',
145                    _ if c.is_alphanumeric() => c.to_ascii_lowercase(),
146                    _ if c.is_whitespace() || c == '-' => '-',
147                    _ => '-',
148                }
149            })
150            .collect::<String>()
151            .split('-')
152            .filter(|s| !s.is_empty())
153            .collect::<Vec<&str>>()
154            .join("-")
155    }
156
157    /// Check if building has public syndic information available
158    pub fn has_public_syndic_info(&self) -> bool {
159        self.syndic_name.is_some() || self.syndic_email.is_some() || self.syndic_phone.is_some()
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_create_building_success() {
169        let org_id = Uuid::new_v4();
170        let building = Building::new(
171            org_id,
172            "Résidence Les Jardins".to_string(),
173            "123 Rue de la Paix".to_string(),
174            "Paris".to_string(),
175            "75001".to_string(),
176            "France".to_string(),
177            50,
178            1000,
179            Some(1985),
180        );
181
182        assert!(building.is_ok());
183        let building = building.unwrap();
184        assert_eq!(building.organization_id, org_id);
185        assert_eq!(building.name, "Résidence Les Jardins");
186        assert_eq!(building.total_units, 50);
187        assert_eq!(building.total_tantiemes, 1000);
188    }
189
190    #[test]
191    fn test_create_building_empty_name_fails() {
192        let org_id = Uuid::new_v4();
193        let building = Building::new(
194            org_id,
195            "".to_string(),
196            "123 Rue de la Paix".to_string(),
197            "Paris".to_string(),
198            "75001".to_string(),
199            "France".to_string(),
200            50,
201            1000,
202            Some(1985),
203        );
204
205        assert!(building.is_err());
206        assert_eq!(building.unwrap_err(), "Building name cannot be empty");
207    }
208
209    #[test]
210    fn test_create_building_zero_units_fails() {
211        let org_id = Uuid::new_v4();
212        let building = Building::new(
213            org_id,
214            "Résidence Les Jardins".to_string(),
215            "123 Rue de la Paix".to_string(),
216            "Paris".to_string(),
217            "75001".to_string(),
218            "France".to_string(),
219            0,
220            1000,
221            Some(1985),
222        );
223
224        assert!(building.is_err());
225        assert_eq!(building.unwrap_err(), "Total units must be greater than 0");
226    }
227
228    #[test]
229    fn test_update_building_info() {
230        let org_id = Uuid::new_v4();
231        let mut building = Building::new(
232            org_id,
233            "Old Name".to_string(),
234            "Old Address".to_string(),
235            "Old City".to_string(),
236            "00000".to_string(),
237            "France".to_string(),
238            10,
239            1000,
240            None,
241        )
242        .unwrap();
243
244        let old_updated_at = building.updated_at;
245
246        building.update_info(
247            "New Name".to_string(),
248            "New Address".to_string(),
249            "New City".to_string(),
250            "11111".to_string(),
251            "France".to_string(),
252            10,
253            1500,
254            None,
255        );
256
257        assert_eq!(building.name, "New Name");
258        assert_eq!(building.address, "New Address");
259        assert_eq!(building.total_tantiemes, 1500);
260        assert!(building.updated_at > old_updated_at);
261    }
262}