Skip to main content

koprogo_api/domain/copropriete/
unit.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Type de lot (appartement, cave, parking, etc.)
7///
8/// `Copy` parce que le décompte légal de l'Art. 3.89 § 5, 15° raisonne sur
9/// des natures de lot, pas sur des lots : les cloner pour les compter serait
10/// du bruit.
11#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
12pub enum UnitType {
13    Apartment,
14    Parking,
15    Cellar,
16    Commercial,
17    Other,
18}
19
20/// Représente un lot dans la copropriété
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct Unit {
23    pub id: Uuid,
24    /// Story H15 — FK vers `acps.id` (anciennement `organization_id`).
25    /// La migration 20260630030000 a DROP la colonne `units.organization_id` ;
26    /// le scoping org se fait désormais via `acps.organization_id` (le lot
27    /// dérive son ACP de son building parent, cf. #602).
28    pub acp_id: Uuid,
29    pub building_id: Uuid,
30    pub unit_number: String,
31    pub unit_type: UnitType,
32    pub floor: Option<i32>,
33    pub surface_area: f64, // en m² (mesure physique, f64 OK — cf. ADR-0009)
34    pub quota: Decimal,    // Quote-part en millièmes (Decimal exact — cf. ADR-0007)
35    pub owner_id: Option<Uuid>,
36    pub created_at: DateTime<Utc>,
37    pub updated_at: DateTime<Utc>,
38}
39
40impl Unit {
41    pub fn new(
42        acp_id: Uuid,
43        building_id: Uuid,
44        unit_number: String,
45        unit_type: UnitType,
46        floor: Option<i32>,
47        surface_area: f64,
48        quota: Decimal,
49    ) -> Result<Self, String> {
50        if unit_number.is_empty() {
51            return Err("Unit number cannot be empty".to_string());
52        }
53        if surface_area <= 0.0 {
54            return Err("Surface area must be greater than 0".to_string());
55        }
56        // Validate shares (tantièmes) — Art. 3.84 CC.
57        // Story H8 (CL2) : plus de borne supérieure hard-codée à 1000. L'acte de
58        // base peut être 1000 / 10000 / autre (cf. ADR-0010). La borne haute
59        // (Σ quotités ≤ acte de base) est vérifiée à l'AGRÉGAT par
60        // `Building::validate_unit_shares_distribution(units, total_tantiemes)`
61        // et `Acp::assert_conformant` — pas au niveau d'un lot isolé qui ignore
62        // l'acte de base de sa copropriété. Ici on garde l'invariant unitaire
63        // minimal : une quote-part est strictement positive.
64        if quota <= Decimal::ZERO {
65            return Err("Quota (shares) must be strictly positive (Art. 3.84 CC)".to_string());
66        }
67
68        let now = Utc::now();
69        Ok(Self {
70            id: Uuid::new_v4(),
71            acp_id,
72            building_id,
73            unit_number,
74            unit_type,
75            floor,
76            surface_area,
77            quota,
78            owner_id: None,
79            created_at: now,
80            updated_at: now,
81        })
82    }
83
84    pub fn validate_update(&self) -> Result<(), String> {
85        if self.unit_number.is_empty() {
86            return Err("Unit number cannot be empty".to_string());
87        }
88        if self.surface_area <= 0.0 {
89            return Err("Surface area must be greater than 0".to_string());
90        }
91        if self.quota <= Decimal::ZERO {
92            return Err("Quota must be strictly positive (Art. 3.84 CC)".to_string());
93        }
94        Ok(())
95    }
96
97    pub fn assign_owner(&mut self, owner_id: Uuid) {
98        self.owner_id = Some(owner_id);
99        self.updated_at = Utc::now();
100    }
101
102    pub fn remove_owner(&mut self) {
103        self.owner_id = None;
104        self.updated_at = Utc::now();
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use rust_decimal_macros::dec;
112
113    // ----- Story H8 (CL2) — borne quotité = acte de base (4-cat) -------------
114
115    #[test]
116    fn happy_unit_quota_5000_on_acte_10000_accepted() {
117        // Avant H8 : rejeté par le cap unitaire 1000. Après : accepté — la borne
118        // supérieure est l'acte de base (10000 ici), vérifiée à l'agrégat.
119        let u = Unit::new(
120            Uuid::new_v4(),
121            Uuid::new_v4(),
122            "B12".to_string(),
123            UnitType::Apartment,
124            Some(2),
125            120.0,
126            dec!(5000),
127        );
128        assert!(
129            u.is_ok(),
130            "quota 5000 doit être accepté (acte de base 10000)"
131        );
132    }
133
134    #[test]
135    fn edge_unit_quota_just_above_1000_accepted() {
136        let u = Unit::new(
137            Uuid::new_v4(),
138            Uuid::new_v4(),
139            "B13".to_string(),
140            UnitType::Apartment,
141            Some(2),
142            60.0,
143            dec!(1001),
144        );
145        assert!(
146            u.is_ok(),
147            "1001 ne doit plus être rejeté (plus de cap 1000)"
148        );
149    }
150
151    #[test]
152    fn security_unit_quota_zero_rejected() {
153        let u = Unit::new(
154            Uuid::new_v4(),
155            Uuid::new_v4(),
156            "B14".to_string(),
157            UnitType::Apartment,
158            None,
159            50.0,
160            Decimal::ZERO,
161        );
162        assert!(u.is_err(), "quota nul rejeté (invariant unitaire minimal)");
163    }
164
165    #[test]
166    fn negative_unit_quota_negative_rejected() {
167        let u = Unit::new(
168            Uuid::new_v4(),
169            Uuid::new_v4(),
170            "B15".to_string(),
171            UnitType::Apartment,
172            None,
173            50.0,
174            dec!(-1),
175        );
176        assert!(u.is_err());
177    }
178
179    #[test]
180    fn test_create_unit_success() {
181        let acp_id = Uuid::new_v4();
182        let building_id = Uuid::new_v4();
183        let unit = Unit::new(
184            acp_id,
185            building_id,
186            "A101".to_string(),
187            UnitType::Apartment,
188            Some(1),
189            75.5,
190            dec!(50),
191        );
192
193        assert!(unit.is_ok());
194        let unit = unit.unwrap();
195        assert_eq!(unit.acp_id, acp_id);
196        assert_eq!(unit.unit_number, "A101");
197        assert_eq!(unit.surface_area, 75.5);
198    }
199
200    #[test]
201    fn test_create_unit_invalid_surface_fails() {
202        let org_id = Uuid::new_v4();
203        let building_id = Uuid::new_v4();
204        let unit = Unit::new(
205            org_id,
206            building_id,
207            "A101".to_string(),
208            UnitType::Apartment,
209            Some(1),
210            0.0,
211            dec!(50),
212        );
213
214        assert!(unit.is_err());
215    }
216
217    #[test]
218    fn test_assign_owner() {
219        let org_id = Uuid::new_v4();
220        let building_id = Uuid::new_v4();
221        let mut unit = Unit::new(
222            org_id,
223            building_id,
224            "A101".to_string(),
225            UnitType::Apartment,
226            Some(1),
227            75.5,
228            dec!(50),
229        )
230        .unwrap();
231
232        let owner_id = Uuid::new_v4();
233        unit.assign_owner(owner_id);
234
235        assert_eq!(unit.owner_id, Some(owner_id));
236    }
237}