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 use rust_decimal::prelude::ToPrimitive;
173 let total_shares_decimal: rust_decimal::Decimal = units.iter().map(|u| u.quota).sum();
174 let total_shares: i32 = total_shares_decimal.trunc().to_i32().unwrap_or(0);
175
176 if total_shares > 1000 {
180 return Err(format!(
181 "Total unit shares ({}) exceeds maximum 1000 (Art. 577-2 §4 CC). \
182 Sum of all unit quotas cannot exceed building total_shares.",
183 total_shares
184 ));
185 }
186
187 Ok(())
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn test_create_building_success() {
197 let org_id = Uuid::new_v4();
198 let building = Building::new(
199 org_id,
200 "Résidence Les Jardins".to_string(),
201 "123 Rue de la Paix".to_string(),
202 "Paris".to_string(),
203 "75001".to_string(),
204 "France".to_string(),
205 50,
206 1000,
207 Some(1985),
208 );
209
210 assert!(building.is_ok());
211 let building = building.unwrap();
212 assert_eq!(building.organization_id, org_id);
213 assert_eq!(building.name, "Résidence Les Jardins");
214 assert_eq!(building.total_units, 50);
215 assert_eq!(building.total_tantiemes, 1000);
216 }
217
218 #[test]
219 fn test_create_building_empty_name_fails() {
220 let org_id = Uuid::new_v4();
221 let building = Building::new(
222 org_id,
223 "".to_string(),
224 "123 Rue de la Paix".to_string(),
225 "Paris".to_string(),
226 "75001".to_string(),
227 "France".to_string(),
228 50,
229 1000,
230 Some(1985),
231 );
232
233 assert!(building.is_err());
234 assert_eq!(building.unwrap_err(), "Building name cannot be empty");
235 }
236
237 #[test]
238 fn test_create_building_zero_units_fails() {
239 let org_id = Uuid::new_v4();
240 let building = Building::new(
241 org_id,
242 "Résidence Les Jardins".to_string(),
243 "123 Rue de la Paix".to_string(),
244 "Paris".to_string(),
245 "75001".to_string(),
246 "France".to_string(),
247 0,
248 1000,
249 Some(1985),
250 );
251
252 assert!(building.is_err());
253 assert_eq!(building.unwrap_err(), "Total units must be greater than 0");
254 }
255
256 #[test]
257 fn test_update_building_info() {
258 let org_id = Uuid::new_v4();
259 let mut building = Building::new(
260 org_id,
261 "Old Name".to_string(),
262 "Old Address".to_string(),
263 "Old City".to_string(),
264 "00000".to_string(),
265 "France".to_string(),
266 10,
267 1000,
268 None,
269 )
270 .unwrap();
271
272 let old_updated_at = building.updated_at;
273
274 building.update_info(
275 "New Name".to_string(),
276 "New Address".to_string(),
277 "New City".to_string(),
278 "11111".to_string(),
279 "France".to_string(),
280 10,
281 1500,
282 None,
283 );
284
285 assert_eq!(building.name, "New Name");
286 assert_eq!(building.address, "New Address");
287 assert_eq!(building.total_tantiemes, 1500);
288 assert!(building.updated_at > old_updated_at);
289 }
290}