koprogo_api/domain/entities/
building.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
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 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 self.slug = Some(Self::generate_slug(&name, &address, &city));
104
105 self.updated_at = Utc::now();
106 }
107
108 #[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 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 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 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 pub fn validate_unit_shares_distribution(
169 units: &[crate::domain::entities::Unit],
170 ) -> Result<(), String> {
171 let total_shares: i32 = units.iter().map(|u| u.quota as i32).sum();
172
173 if total_shares > 1000 {
177 return Err(format!(
178 "Total unit shares ({}) exceeds maximum 1000 (Art. 577-2 §4 CC). \
179 Sum of all unit quotas cannot exceed building total_shares.",
180 total_shares
181 ));
182 }
183
184 Ok(())
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn test_create_building_success() {
194 let org_id = Uuid::new_v4();
195 let building = Building::new(
196 org_id,
197 "Résidence Les Jardins".to_string(),
198 "123 Rue de la Paix".to_string(),
199 "Paris".to_string(),
200 "75001".to_string(),
201 "France".to_string(),
202 50,
203 1000,
204 Some(1985),
205 );
206
207 assert!(building.is_ok());
208 let building = building.unwrap();
209 assert_eq!(building.organization_id, org_id);
210 assert_eq!(building.name, "Résidence Les Jardins");
211 assert_eq!(building.total_units, 50);
212 assert_eq!(building.total_tantiemes, 1000);
213 }
214
215 #[test]
216 fn test_create_building_empty_name_fails() {
217 let org_id = Uuid::new_v4();
218 let building = Building::new(
219 org_id,
220 "".to_string(),
221 "123 Rue de la Paix".to_string(),
222 "Paris".to_string(),
223 "75001".to_string(),
224 "France".to_string(),
225 50,
226 1000,
227 Some(1985),
228 );
229
230 assert!(building.is_err());
231 assert_eq!(building.unwrap_err(), "Building name cannot be empty");
232 }
233
234 #[test]
235 fn test_create_building_zero_units_fails() {
236 let org_id = Uuid::new_v4();
237 let building = Building::new(
238 org_id,
239 "Résidence Les Jardins".to_string(),
240 "123 Rue de la Paix".to_string(),
241 "Paris".to_string(),
242 "75001".to_string(),
243 "France".to_string(),
244 0,
245 1000,
246 Some(1985),
247 );
248
249 assert!(building.is_err());
250 assert_eq!(building.unwrap_err(), "Total units must be greater than 0");
251 }
252
253 #[test]
254 fn test_update_building_info() {
255 let org_id = Uuid::new_v4();
256 let mut building = Building::new(
257 org_id,
258 "Old Name".to_string(),
259 "Old Address".to_string(),
260 "Old City".to_string(),
261 "00000".to_string(),
262 "France".to_string(),
263 10,
264 1000,
265 None,
266 )
267 .unwrap();
268
269 let old_updated_at = building.updated_at;
270
271 building.update_info(
272 "New Name".to_string(),
273 "New Address".to_string(),
274 "New City".to_string(),
275 "11111".to_string(),
276 "France".to_string(),
277 10,
278 1500,
279 None,
280 );
281
282 assert_eq!(building.name, "New Name");
283 assert_eq!(building.address, "New Address");
284 assert_eq!(building.total_tantiemes, 1500);
285 assert!(building.updated_at > old_updated_at);
286 }
287}