1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3#[cfg(test)]
4use rust_decimal_macros::dec;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct BuildingMetrics {
15 pub units_count: i32,
17 pub quota_sum: Decimal,
19}
20
21impl BuildingMetrics {
22 pub fn empty() -> Self {
24 Self {
25 units_count: 0,
26 quota_sum: Decimal::ZERO,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
33pub struct Building {
34 pub id: Uuid,
35 pub acp_id: Uuid,
39 pub name: String,
40 pub address: String,
41 pub city: String,
42 pub postal_code: String,
43 pub country: String,
44 pub total_units: i32,
45 pub total_tantiemes: i32,
46 pub construction_year: Option<i32>,
47
48 pub syndic_name: Option<String>,
50 pub syndic_email: Option<String>,
51 pub syndic_phone: Option<String>,
52 pub syndic_address: Option<String>,
53 pub syndic_office_hours: Option<String>,
54 pub syndic_emergency_contact: Option<String>,
55 pub slug: Option<String>,
56
57 pub created_at: DateTime<Utc>,
58 pub updated_at: DateTime<Utc>,
59}
60
61impl Building {
62 #[allow(clippy::too_many_arguments)]
63 pub fn new(
64 acp_id: Uuid,
65 name: String,
66 address: String,
67 city: String,
68 postal_code: String,
69 country: String,
70 total_units: i32,
71 total_tantiemes: i32,
72 construction_year: Option<i32>,
73 ) -> Result<Self, String> {
74 if name.is_empty() {
75 return Err("Building name cannot be empty".to_string());
76 }
77 if total_units <= 0 {
78 return Err("Total units must be greater than 0".to_string());
79 }
80 if total_tantiemes <= 0 {
81 return Err("Total tantiemes must be greater than 0".to_string());
82 }
83
84 let now = Utc::now();
85 let slug = Self::generate_slug(&name, &address, &city);
86
87 Ok(Self {
88 id: Uuid::new_v4(),
89 acp_id,
90 name,
91 address,
92 city,
93 postal_code,
94 country,
95 total_units,
96 total_tantiemes,
97 construction_year,
98 syndic_name: None,
99 syndic_email: None,
100 syndic_phone: None,
101 syndic_address: None,
102 syndic_office_hours: None,
103 syndic_emergency_contact: None,
104 slug: Some(slug),
105 created_at: now,
106 updated_at: now,
107 })
108 }
109
110 #[allow(clippy::too_many_arguments)]
111 pub fn update_info(
112 &mut self,
113 name: String,
114 address: String,
115 city: String,
116 postal_code: String,
117 country: String,
118 total_units: i32,
119 total_tantiemes: i32,
120 construction_year: Option<i32>,
121 ) {
122 self.name = name.clone();
123 self.address = address.clone();
124 self.city = city.clone();
125 self.postal_code = postal_code;
126 self.country = country;
127 self.total_units = total_units;
128 self.total_tantiemes = total_tantiemes;
129 self.construction_year = construction_year;
130
131 self.slug = Some(Self::generate_slug(&name, &address, &city));
133
134 self.updated_at = Utc::now();
135 }
136
137 #[allow(clippy::too_many_arguments)]
139 pub fn update_syndic_info(
140 &mut self,
141 syndic_name: Option<String>,
142 syndic_email: Option<String>,
143 syndic_phone: Option<String>,
144 syndic_address: Option<String>,
145 syndic_office_hours: Option<String>,
146 syndic_emergency_contact: Option<String>,
147 ) {
148 self.syndic_name = syndic_name;
149 self.syndic_email = syndic_email;
150 self.syndic_phone = syndic_phone;
151 self.syndic_address = syndic_address;
152 self.syndic_office_hours = syndic_office_hours;
153 self.syndic_emergency_contact = syndic_emergency_contact;
154 self.updated_at = Utc::now();
155 }
156
157 fn generate_slug(name: &str, _address: &str, city: &str) -> String {
160 let combined = format!("{} {}", name, city);
161
162 combined
163 .chars()
164 .map(|c| {
165 match c {
167 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'à' | 'á' | 'â' | 'ã' | 'ä' => 'a',
168 'È' | 'É' | 'Ê' | 'Ë' | 'è' | 'é' | 'ê' | 'ë' => 'e',
169 'Ì' | 'Í' | 'Î' | 'Ï' | 'ì' | 'í' | 'î' | 'ï' => 'i',
170 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o',
171 'Ù' | 'Ú' | 'Û' | 'Ü' | 'ù' | 'ú' | 'û' | 'ü' => 'u',
172 'Ç' | 'ç' => 'c',
173 'Ñ' | 'ñ' => 'n',
174 _ if c.is_alphanumeric() => c.to_ascii_lowercase(),
175 _ if c.is_whitespace() || c == '-' => '-',
176 _ => '-',
177 }
178 })
179 .collect::<String>()
180 .split('-')
181 .filter(|s| !s.is_empty())
182 .collect::<Vec<&str>>()
183 .join("-")
184 }
185
186 pub fn has_public_syndic_info(&self) -> bool {
188 self.syndic_name.is_some() || self.syndic_email.is_some() || self.syndic_phone.is_some()
189 }
190
191 pub fn is_conformant(&self, metrics: &BuildingMetrics) -> bool {
216 Self::compute_is_conformant(self.total_units, self.total_tantiemes, metrics)
217 }
218
219 pub fn compute_is_conformant(
232 _declared_units: i32,
233 total_tantiemes: i32,
234 metrics: &BuildingMetrics,
235 ) -> bool {
236 metrics.quota_sum == Decimal::from(total_tantiemes)
237 }
238
239 pub fn quota_delta(&self, metrics: &BuildingMetrics) -> Decimal {
248 Decimal::from(self.total_tantiemes) - metrics.quota_sum
249 }
250
251 pub fn assert_conformant(
255 &self,
256 metrics: &BuildingMetrics,
257 ) -> Result<(), BuildingNotConformantError> {
258 if !self.is_conformant(metrics) {
259 return Err(BuildingNotConformantError {
260 building_id: self.id,
261 units_delta: self.total_units - metrics.units_count,
262 quota_delta: self.quota_delta(metrics),
263 quota_basis: self.total_tantiemes,
264 });
265 }
266 Ok(())
267 }
268
269 pub fn validate_unit_shares_distribution(
279 units: &[crate::domain::entities::Unit],
280 total_tantiemes: i32,
281 ) -> Result<(), String> {
282 use rust_decimal::prelude::ToPrimitive;
284 let total_shares_decimal: rust_decimal::Decimal = units.iter().map(|u| u.quota).sum();
285 let total_shares: i32 = total_shares_decimal.trunc().to_i32().unwrap_or(0);
286
287 if total_shares > total_tantiemes {
291 return Err(format!(
292 "Total unit shares ({}) exceeds acte de base ({}) (Art. 577-2 §4 CC). \
293 Sum of all unit quotas cannot exceed building total_tantiemes.",
294 total_shares, total_tantiemes
295 ));
296 }
297
298 Ok(())
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct BuildingNotConformantError {
315 pub building_id: Uuid,
316 pub units_delta: i32,
317 pub quota_delta: Decimal,
318 pub quota_basis: i32,
319}
320
321impl std::fmt::Display for BuildingNotConformantError {
322 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323 write!(
324 f,
325 "Building {} not conformant: {} units missing, quota delta {} / {} (acte de base)",
326 self.building_id, self.units_delta, self.quota_delta, self.quota_basis
327 )
328 }
329}
330
331impl std::error::Error for BuildingNotConformantError {}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn test_create_building_success() {
339 let acp_id = Uuid::new_v4();
340 let building = Building::new(
341 acp_id,
342 "Résidence Les Jardins".to_string(),
343 "123 Rue de la Paix".to_string(),
344 "Paris".to_string(),
345 "75001".to_string(),
346 "France".to_string(),
347 50,
348 1000,
349 Some(1985),
350 );
351
352 assert!(building.is_ok());
353 let building = building.unwrap();
354 assert_eq!(building.acp_id, acp_id);
355 assert_eq!(building.name, "Résidence Les Jardins");
356 assert_eq!(building.total_units, 50);
357 assert_eq!(building.total_tantiemes, 1000);
358 }
359
360 #[test]
361 fn test_create_building_empty_name_fails() {
362 let acp_id = Uuid::new_v4();
363 let building = Building::new(
364 acp_id,
365 "".to_string(),
366 "123 Rue de la Paix".to_string(),
367 "Paris".to_string(),
368 "75001".to_string(),
369 "France".to_string(),
370 50,
371 1000,
372 Some(1985),
373 );
374
375 assert!(building.is_err());
376 assert_eq!(building.unwrap_err(), "Building name cannot be empty");
377 }
378
379 #[test]
380 fn test_create_building_zero_units_fails() {
381 let acp_id = Uuid::new_v4();
382 let building = Building::new(
383 acp_id,
384 "Résidence Les Jardins".to_string(),
385 "123 Rue de la Paix".to_string(),
386 "Paris".to_string(),
387 "75001".to_string(),
388 "France".to_string(),
389 0,
390 1000,
391 Some(1985),
392 );
393
394 assert!(building.is_err());
395 assert_eq!(building.unwrap_err(), "Total units must be greater than 0");
396 }
397
398 fn make_building(total_units: i32) -> Building {
403 Building::new(
404 Uuid::new_v4(),
405 "Test".to_string(),
406 "Rue Test 1".to_string(),
407 "Bruxelles".to_string(),
408 "1000".to_string(),
409 "Belgium".to_string(),
410 total_units,
411 1000,
412 None,
413 )
414 .unwrap()
415 }
416
417 #[test]
418 fn happy_is_conformant_when_count_matches_and_quota_1000() {
419 let b = make_building(2);
420 let metrics = BuildingMetrics {
421 units_count: 2,
422 quota_sum: dec!(1000),
423 };
424 assert!(b.is_conformant(&metrics));
425 assert_eq!(b.quota_delta(&metrics), dec!(0));
426 }
427
428 #[test]
429 fn edge_is_not_conformant_when_quota_off_by_one_millieme() {
430 let b = make_building(2);
431 let metrics = BuildingMetrics {
432 units_count: 2,
433 quota_sum: dec!(999),
434 };
435 assert!(!b.is_conformant(&metrics), "no rounding tolerance");
436 assert_eq!(b.quota_delta(&metrics), dec!(1));
439 }
440
441 #[test]
443 fn edge_is_conformant_meme_si_le_compte_de_lots_diverge() {
444 let b = make_building(3);
445 let metrics = BuildingMetrics {
446 units_count: 2,
447 quota_sum: dec!(1000),
448 };
449 assert!(
450 b.is_conformant(&metrics),
451 "les quotités totalisent l'acte : le compte de lots déclaré ne doit \
452 plus fermer la comptabilité (#770)"
453 );
454 }
455
456 #[test]
458 fn negative_is_not_conformant_quand_les_quotites_manquent() {
459 let b = make_building(3);
460 let metrics = BuildingMetrics {
461 units_count: 3,
462 quota_sum: dec!(999),
463 };
464 assert!(!b.is_conformant(&metrics));
465 }
466
467 #[test]
468 fn edge_empty_metrics_returns_zero_not_nan() {
469 let metrics = BuildingMetrics::empty();
470 assert_eq!(metrics.quota_sum, Decimal::ZERO);
471 assert_eq!(metrics.units_count, 0);
472 let b = make_building(1);
473 assert!(!b.is_conformant(&metrics));
474 assert_eq!(b.quota_delta(&metrics), dec!(1000));
476 }
477
478 #[test]
479 fn security_compute_is_conformant_is_pure_no_side_effect() {
480 assert!(Building::compute_is_conformant(
483 3,
484 1000,
485 &BuildingMetrics {
486 units_count: 3,
487 quota_sum: dec!(1000),
488 }
489 ));
490 assert!(!Building::compute_is_conformant(
491 3,
492 1000,
493 &BuildingMetrics {
494 units_count: 3,
495 quota_sum: dec!(1001),
496 }
497 ));
498 }
499
500 #[test]
501 fn negative_quota_delta_for_surplus_is_negative() {
502 let metrics = BuildingMetrics {
505 units_count: 2,
506 quota_sum: dec!(1500),
507 };
508 let b = make_building(2);
509 assert_eq!(b.quota_delta(&metrics), dec!(-500));
510 }
511
512 #[test]
513 fn negative_quota_delta_for_deficit_is_positive() {
514 let metrics = BuildingMetrics {
517 units_count: 2,
518 quota_sum: dec!(900),
519 };
520 let b = make_building(2);
521 assert_eq!(b.quota_delta(&metrics), dec!(100));
522 }
523
524 #[test]
525 fn test_update_building_info() {
526 let acp_id = Uuid::new_v4();
527 let mut building = Building::new(
528 acp_id,
529 "Old Name".to_string(),
530 "Old Address".to_string(),
531 "Old City".to_string(),
532 "00000".to_string(),
533 "France".to_string(),
534 10,
535 1000,
536 None,
537 )
538 .unwrap();
539
540 let old_updated_at = building.updated_at;
541
542 building.update_info(
543 "New Name".to_string(),
544 "New Address".to_string(),
545 "New City".to_string(),
546 "11111".to_string(),
547 "France".to_string(),
548 10,
549 1500,
550 None,
551 );
552
553 assert_eq!(building.name, "New Name");
554 assert_eq!(building.address, "New Address");
555 assert_eq!(building.total_tantiemes, 1500);
556 assert!(building.updated_at > old_updated_at);
557 }
558}
559
560#[cfg(test)]
568mod assert_conformant_tests {
569 use super::*;
570
571 fn make_building_with_basis(total_units: i32, total_tantiemes: i32) -> Building {
572 Building::new(
573 Uuid::new_v4(),
574 format!("Test {}/{}", total_units, total_tantiemes),
575 "Rue Test 1".to_string(),
576 "Bruxelles".to_string(),
577 "1000".to_string(),
578 "Belgium".to_string(),
579 total_units,
580 total_tantiemes,
581 None,
582 )
583 .unwrap()
584 }
585
586 #[test]
591 fn happy_returns_ok_when_conformant_1000() {
592 let b = make_building_with_basis(10, 1000);
594 let metrics = BuildingMetrics {
595 units_count: 10,
596 quota_sum: dec!(1000),
597 };
598 assert!(b.assert_conformant(&metrics).is_ok());
599 }
600
601 #[test]
602 fn happy_returns_ok_when_conformant_10000() {
603 let b = make_building_with_basis(182, 10000);
606 let metrics = BuildingMetrics {
607 units_count: 182,
608 quota_sum: dec!(10000),
609 };
610 assert!(b.assert_conformant(&metrics).is_ok());
611 }
612
613 #[test]
614 fn happy_returns_ok_when_conformant_exotic_500() {
615 let b = make_building_with_basis(5, 500);
617 let metrics = BuildingMetrics {
618 units_count: 5,
619 quota_sum: dec!(500),
620 };
621 assert!(b.assert_conformant(&metrics).is_ok());
622 }
623
624 #[test]
629 fn edge_quota_off_by_one_tenth_fails_1000() {
630 let b = make_building_with_basis(10, 1000);
632 let metrics = BuildingMetrics {
633 units_count: 10,
634 quota_sum: dec!(999.9),
635 };
636 let err = b.assert_conformant(&metrics).unwrap_err();
637 assert_eq!(err.quota_delta, dec!(0.1));
638 assert_eq!(err.units_delta, 0);
639 assert_eq!(err.quota_basis, 1000);
640 }
641
642 #[test]
643 fn edge_quota_off_by_one_tenth_fails_10000() {
644 let b = make_building_with_basis(182, 10000);
646 let metrics = BuildingMetrics {
647 units_count: 182,
648 quota_sum: dec!(9999.9),
649 };
650 let err = b.assert_conformant(&metrics).unwrap_err();
651 assert_eq!(err.quota_delta, dec!(0.1));
652 assert_eq!(err.units_delta, 0);
653 assert_eq!(err.quota_basis, 10000);
654 }
655
656 #[test]
661 fn edge_units_mismatch_with_quota_correct_passe() {
662 let b = make_building_with_basis(10, 1000);
663 let metrics = BuildingMetrics {
664 units_count: 9,
665 quota_sum: dec!(1000),
666 };
667 assert!(b.assert_conformant(&metrics).is_ok());
668 }
669
670 #[test]
671 fn edge_quota_basis_10000_drift_181_units_975_short() {
672 let b = make_building_with_basis(182, 10000);
674 let metrics = BuildingMetrics {
675 units_count: 181,
676 quota_sum: dec!(9975),
677 };
678 let err = b.assert_conformant(&metrics).unwrap_err();
679 assert_eq!(err.units_delta, 1);
680 assert_eq!(err.quota_delta, dec!(25));
681 assert_eq!(err.quota_basis, 10000);
682 }
683
684 #[test]
689 fn security_metrics_tampering_changes_outcome_but_is_pure() {
690 let b = make_building_with_basis(10, 1000);
695 let forged_conformant = BuildingMetrics {
696 units_count: 10,
697 quota_sum: dec!(1000),
698 };
699 assert!(b.assert_conformant(&forged_conformant).is_ok());
700
701 let forged_non_conformant = BuildingMetrics {
704 units_count: 10,
705 quota_sum: dec!(999),
706 };
707 let err = b.assert_conformant(&forged_non_conformant).unwrap_err();
708 assert_eq!(err.quota_delta, dec!(1));
709 }
711
712 #[test]
713 fn security_error_struct_is_debug_safe() {
714 let err = BuildingNotConformantError {
717 building_id: Uuid::new_v4(),
718 units_delta: 1,
719 quota_delta: dec!(0.5),
720 quota_basis: 1000,
721 };
722 let debug_string = format!("{:?}", err);
723 assert!(debug_string.contains("building_id"));
724 assert!(debug_string.contains("units_delta"));
725 }
727
728 #[test]
733 fn negative_empty_metrics_yields_full_deltas() {
734 let b = make_building_with_basis(10, 1000);
737 let metrics = BuildingMetrics::empty();
738 let err = b.assert_conformant(&metrics).unwrap_err();
739 assert_eq!(err.units_delta, 10);
740 assert_eq!(err.quota_delta, dec!(1000));
741 assert_eq!(err.quota_basis, 1000);
742 }
743
744 #[test]
745 fn negative_empty_metrics_full_deltas_10000() {
746 let b = make_building_with_basis(182, 10000);
748 let metrics = BuildingMetrics::empty();
749 let err = b.assert_conformant(&metrics).unwrap_err();
750 assert_eq!(err.units_delta, 182);
751 assert_eq!(err.quota_delta, dec!(10000));
752 assert_eq!(err.quota_basis, 10000);
753 }
754
755 #[test]
756 fn negative_display_format_contains_basis() {
757 let err = BuildingNotConformantError {
759 building_id: Uuid::nil(),
760 units_delta: 1,
761 quota_delta: dec!(25),
762 quota_basis: 10000,
763 };
764 let s = format!("{}", err);
765 assert!(
766 s.contains("10000"),
767 "Display should include quota_basis: {}",
768 s
769 );
770 assert!(
771 s.contains("25"),
772 "Display should include quota_delta: {}",
773 s
774 );
775 }
776}