Skip to main content

koprogo_api/domain/copropriete/
technical_inspection.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Technical Inspection - Inspection technique obligatoire
7///
8/// Tracks mandatory technical inspections for building equipment and systems.
9/// Belgian law requires regular inspections for safety-critical equipment.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct TechnicalInspection {
12    pub id: Uuid,
13    pub organization_id: Uuid,
14    pub building_id: Uuid,
15
16    // Inspection details
17    pub inspection_type: InspectionType,
18    pub title: String,
19    pub description: Option<String>,
20
21    // Inspector info
22    pub inspector_name: String,
23    pub inspector_company: Option<String>,
24    pub inspector_certification: Option<String>, // Certification number
25
26    // Dates
27    pub inspection_date: DateTime<Utc>,
28    pub next_due_date: DateTime<Utc>, // When next inspection is due
29
30    // Results
31    pub status: InspectionStatus,
32    pub result_summary: Option<String>,
33    pub defects_found: Option<String>,
34    pub recommendations: Option<String>,
35
36    // Compliance
37    pub compliant: Option<bool>,
38    pub compliance_certificate_number: Option<String>,
39    pub compliance_valid_until: Option<DateTime<Utc>>,
40
41    // Financial
42    /// Coût de l'inspection en EUR. `Decimal` et non `f64` : montant
43    /// refacturé via la répartition des charges (Art. 3.86 CC) —
44    /// ADR-0007/0008 §A.
45    pub cost: Option<Decimal>,
46    pub invoice_number: Option<String>,
47
48    // Documentation (JSON arrays of file paths)
49    pub reports: Vec<String>,
50    pub photos: Vec<String>,
51    pub certificates: Vec<String>,
52    pub notes: Option<String>,
53
54    // Metadata
55    pub created_at: DateTime<Utc>,
56    pub updated_at: DateTime<Utc>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
60#[serde(rename_all = "snake_case")]
61pub enum InspectionType {
62    Elevator,               // Ascenseur (annuel)
63    Boiler,                 // Chaudière (annuel)
64    Electrical,             // Installation électrique (5 ans)
65    FireExtinguisher,       // Extincteurs (annuel)
66    FireAlarm,              // Système d'alarme incendie (annuel)
67    GasInstallation,        // Installation gaz (annuel)
68    RoofStructure,          // Structure toiture (5 ans)
69    Facade,                 // Façade (quinquennal)
70    WaterQuality,           // Qualité eau (annuel)
71    Other { name: String }, // Autre type d'inspection
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
75#[serde(rename_all = "snake_case")]
76pub enum InspectionStatus {
77    Scheduled,  // Planifiée
78    InProgress, // En cours
79    Completed,  // Terminée
80    Failed,     // Échec (non conforme)
81    Overdue,    // En retard
82    Cancelled,  // Annulée
83}
84
85impl InspectionType {
86    /// Get the required inspection frequency in days
87    pub fn frequency_days(&self) -> i64 {
88        match self {
89            InspectionType::Elevator => 365,          // Annual
90            InspectionType::Boiler => 365,            // Annual
91            InspectionType::Electrical => 365 * 5,    // Every 5 years
92            InspectionType::FireExtinguisher => 365,  // Annual
93            InspectionType::FireAlarm => 365,         // Annual
94            InspectionType::GasInstallation => 365,   // Annual
95            InspectionType::RoofStructure => 365 * 5, // Every 5 years
96            InspectionType::Facade => 365 * 5,        // Every 5 years
97            InspectionType::WaterQuality => 365,      // Annual
98            InspectionType::Other { .. } => 365,      // Default annual
99        }
100    }
101
102    /// Get human-readable name
103    pub fn display_name(&self) -> String {
104        match self {
105            InspectionType::Elevator => "Ascenseur".to_string(),
106            InspectionType::Boiler => "Chaudière".to_string(),
107            InspectionType::Electrical => "Installation électrique".to_string(),
108            InspectionType::FireExtinguisher => "Extincteurs".to_string(),
109            InspectionType::FireAlarm => "Alarme incendie".to_string(),
110            InspectionType::GasInstallation => "Installation gaz".to_string(),
111            InspectionType::RoofStructure => "Structure toiture".to_string(),
112            InspectionType::Facade => "Façade".to_string(),
113            InspectionType::WaterQuality => "Qualité de l'eau".to_string(),
114            InspectionType::Other { name } => name.clone(),
115        }
116    }
117}
118
119/// Erreurs de validation du domaine `TechnicalInspection`.
120///
121/// Type domaine pur — aucune dépendance infra/application (pureté hexagonale).
122#[derive(Debug, Clone, PartialEq)]
123pub enum TechnicalInspectionError {
124    /// Coût d'inspection strictement négatif.
125    NegativeCost,
126}
127
128impl std::fmt::Display for TechnicalInspectionError {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        match self {
131            Self::NegativeCost => write!(f, "Technical inspection cost cannot be negative"),
132        }
133    }
134}
135
136impl std::error::Error for TechnicalInspectionError {}
137
138/// Bridge : use-cases/ports `Result<_, String>` inchangés.
139impl From<TechnicalInspectionError> for String {
140    fn from(e: TechnicalInspectionError) -> String {
141        e.to_string()
142    }
143}
144
145impl TechnicalInspection {
146    /// Pose le coût en portant l'invariant de non-négativité.
147    ///
148    /// `TechnicalInspection::new` ne prend pas de coût (il est renseigné plus
149    /// tard, à la facturation) : l'invariant que portait
150    /// `#[validate(range(min = 0.0))]` côté DTO se place donc ici, au seul
151    /// point d'écriture, plutôt que de disparaître avec l'annotation.
152    pub fn set_cost(&mut self, cost: Option<Decimal>) -> Result<(), TechnicalInspectionError> {
153        if let Some(value) = cost {
154            if value < Decimal::ZERO {
155                return Err(TechnicalInspectionError::NegativeCost);
156            }
157        }
158        self.cost = cost;
159        self.updated_at = Utc::now();
160        Ok(())
161    }
162
163    #[allow(clippy::too_many_arguments)]
164    pub fn new(
165        organization_id: Uuid,
166        building_id: Uuid,
167        title: String,
168        description: Option<String>,
169        inspection_type: InspectionType,
170        inspector_name: String,
171        inspection_date: DateTime<Utc>,
172    ) -> Self {
173        let now = Utc::now();
174
175        // Calculate next due date based on inspection type
176        let next_due_date =
177            inspection_date + chrono::Duration::days(inspection_type.frequency_days());
178
179        Self {
180            id: Uuid::new_v4(),
181            organization_id,
182            building_id,
183            inspection_type,
184            title,
185            description,
186            inspector_name,
187            inspector_company: None,
188            inspector_certification: None,
189            inspection_date,
190            next_due_date,
191            status: InspectionStatus::Scheduled,
192            result_summary: None,
193            defects_found: None,
194            recommendations: None,
195            compliant: None,
196            compliance_certificate_number: None,
197            compliance_valid_until: None,
198            cost: None,
199            invoice_number: None,
200            reports: Vec::new(),
201            photos: Vec::new(),
202            certificates: Vec::new(),
203            notes: None,
204            created_at: now,
205            updated_at: now,
206        }
207    }
208
209    /// Calculate next due date based on inspection type
210    pub fn calculate_next_due_date(&self) -> DateTime<Utc> {
211        self.inspection_date + chrono::Duration::days(self.inspection_type.frequency_days())
212    }
213
214    /// Check if inspection is overdue
215    pub fn is_overdue(&self) -> bool {
216        Utc::now() > self.next_due_date
217    }
218
219    /// Get days until next inspection is due (negative if overdue)
220    pub fn days_until_due(&self) -> i64 {
221        (self.next_due_date - Utc::now()).num_days()
222    }
223
224    /// Mark as overdue
225    pub fn mark_overdue(&mut self) {
226        if self.is_overdue() && self.status == InspectionStatus::Scheduled {
227            self.status = InspectionStatus::Overdue;
228            self.updated_at = Utc::now();
229        }
230    }
231
232    /// Add report to inspection
233    pub fn add_report(&mut self, report_path: String) {
234        self.reports.push(report_path);
235        self.updated_at = Utc::now();
236    }
237
238    /// Add photo to inspection
239    pub fn add_photo(&mut self, photo_path: String) {
240        self.photos.push(photo_path);
241        self.updated_at = Utc::now();
242    }
243
244    /// Add certificate to inspection
245    pub fn add_certificate(&mut self, certificate_path: String) {
246        self.certificates.push(certificate_path);
247        self.updated_at = Utc::now();
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use rust_decimal_macros::dec;
255
256    #[test]
257    fn test_inspection_creation() {
258        let inspection = TechnicalInspection::new(
259            Uuid::new_v4(),
260            Uuid::new_v4(),
261            "Inspection annuelle ascenseur".to_string(),
262            Some("Vérification complète".to_string()),
263            InspectionType::Elevator,
264            "Schindler Belgium".to_string(),
265            Utc::now(),
266        );
267
268        assert_eq!(inspection.title, "Inspection annuelle ascenseur");
269        assert_eq!(inspection.status, InspectionStatus::Scheduled);
270        assert!(!inspection.is_overdue());
271    }
272
273    #[test]
274    fn test_inspection_frequencies() {
275        assert_eq!(InspectionType::Elevator.frequency_days(), 365);
276        assert_eq!(InspectionType::Electrical.frequency_days(), 365 * 5);
277        assert_eq!(InspectionType::Facade.frequency_days(), 365 * 5);
278    }
279
280    #[test]
281    fn test_inspection_completion() {
282        let mut inspection = TechnicalInspection::new(
283            Uuid::new_v4(),
284            Uuid::new_v4(),
285            "Inspection chaudière".to_string(),
286            None,
287            InspectionType::Boiler,
288            "Test Inspector".to_string(),
289            Utc::now(),
290        );
291
292        inspection.status = InspectionStatus::Completed;
293        inspection.compliant = Some(true);
294        assert_eq!(inspection.status, InspectionStatus::Completed);
295        assert_eq!(inspection.compliant, Some(true));
296    }
297
298    #[test]
299    fn test_overdue_detection() {
300        let past_date = Utc::now() - chrono::Duration::days(400); // Over a year ago
301        let mut inspection = TechnicalInspection::new(
302            Uuid::new_v4(),
303            Uuid::new_v4(),
304            "Test".to_string(),
305            None,
306            InspectionType::FireExtinguisher,
307            "Test".to_string(),
308            past_date,
309        );
310
311        assert!(inspection.is_overdue());
312        assert!(inspection.days_until_due() < 0);
313
314        inspection.mark_overdue();
315        assert_eq!(inspection.status, InspectionStatus::Overdue);
316    }
317
318    // ----- set_cost : ADR-0008, tests 4-cat -------------------------------
319
320    fn make_inspection() -> TechnicalInspection {
321        TechnicalInspection::new(
322            Uuid::new_v4(),
323            Uuid::new_v4(),
324            "Inspection annuelle ascenseur".to_string(),
325            None,
326            InspectionType::Elevator,
327            "Schindler Belgium".to_string(),
328            Utc::now(),
329        )
330    }
331
332    /// @happy — le coût se pose et l'horodatage suit.
333    #[test]
334    fn happy_set_cost_records_the_amount() {
335        let mut inspection = make_inspection();
336        let before = inspection.updated_at;
337
338        inspection
339            .set_cost(Some(dec!(450.00)))
340            .expect("coût valide");
341
342        assert_eq!(inspection.cost, Some(dec!(450.00)));
343        assert!(inspection.updated_at >= before);
344    }
345
346    /// @happy — `None` est légitime : inspection planifiée, pas encore facturée.
347    #[test]
348    fn happy_set_cost_none_is_accepted() {
349        let mut inspection = make_inspection();
350        inspection.set_cost(Some(dec!(10.00))).expect("coût valide");
351
352        inspection.set_cost(None).expect("absence de coût valide");
353
354        assert_eq!(inspection.cost, None);
355    }
356
357    /// @edge — zéro accepté (inspection sous contrat déjà réglé), le centime
358    /// négatif refusé : la borne est à zéro exclu du côté négatif.
359    #[test]
360    fn edge_zero_accepted_minus_one_cent_rejected() {
361        let mut inspection = make_inspection();
362
363        inspection
364            .set_cost(Some(Decimal::ZERO))
365            .expect("zéro est un coût valide");
366        assert_eq!(inspection.cost, Some(Decimal::ZERO));
367
368        assert_eq!(
369            inspection.set_cost(Some(dec!(-0.01))).unwrap_err(),
370            TechnicalInspectionError::NegativeCost
371        );
372    }
373
374    /// @edge — exactitude décimale, raison d'être de la conversion : en
375    /// binary64 cette égalité est fausse.
376    #[test]
377    fn edge_decimal_arithmetic_is_exact() {
378        let mut inspection = make_inspection();
379        inspection.set_cost(Some(dec!(0.10))).expect("coût valide");
380
381        let cumulated = inspection.cost.expect("coût posé") + dec!(0.20);
382        inspection.set_cost(Some(cumulated)).expect("coût valide");
383
384        assert_eq!(inspection.cost, Some(dec!(0.30)));
385    }
386
387    /// @negative — un refus ne laisse aucune écriture partielle derrière lui.
388    #[test]
389    fn negative_rejected_set_cost_leaves_the_entity_untouched() {
390        let mut inspection = make_inspection();
391        inspection
392            .set_cost(Some(dec!(120.00)))
393            .expect("coût valide");
394        let before = inspection.updated_at;
395
396        let _ = inspection.set_cost(Some(dec!(-5.00)));
397
398        assert_eq!(inspection.cost, Some(dec!(120.00)));
399        assert_eq!(inspection.updated_at, before);
400    }
401
402    /// @security — un coût négatif refacturé via la répartition des charges
403    /// (Art. 3.86 CC) produirait un avoir au profit des copropriétaires depuis
404    /// une simple fiche d'inspection. L'invariant tient dans le domaine, donc
405    /// hors d'atteinte d'un contournement de la route HTTP.
406    #[test]
407    fn security_negative_cost_cannot_bypass_the_domain() {
408        let mut inspection = make_inspection();
409
410        assert!(inspection.set_cost(Some(dec!(-999999.99))).is_err());
411        assert_eq!(inspection.cost, None);
412    }
413}