Skip to main content

koprogo_api/domain/economie_circulaire/
work_report.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Work Report - Rapport de travaux effectués
7///
8/// Tracks maintenance work, repairs, and renovations performed on the building.
9/// Part of the digital maintenance logbook (Carnet d'Entretien).
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct WorkReport {
12    pub id: Uuid,
13    pub organization_id: Uuid,
14    pub building_id: Uuid,
15
16    // Work details
17    pub title: String,
18    pub description: String,
19    pub work_type: WorkType,
20    pub contractor_name: String,
21    pub contractor_contact: Option<String>,
22
23    // Dates
24    pub work_date: DateTime<Utc>,               // Date of work
25    pub completion_date: Option<DateTime<Utc>>, // If different from work_date
26
27    // Financial
28    /// Coût des travaux en EUR. `Decimal` et non `f64` : ce montant est
29    /// refacturé aux copropriétaires via la répartition des charges
30    /// (Art. 3.86 CC) et alimente le fonds de réserve — ADR-0007/0008 §A.
31    pub cost: Decimal,
32    pub invoice_number: Option<String>,
33
34    // Documentation
35    pub photos: Vec<String>,    // File paths to photos
36    pub documents: Vec<String>, // File paths to related documents
37    pub notes: Option<String>,
38
39    // Warranty tracking
40    pub warranty_type: WarrantyType,
41    pub warranty_expiry: DateTime<Utc>,
42
43    // Metadata
44    pub created_at: DateTime<Utc>,
45    pub updated_at: DateTime<Utc>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
49#[serde(rename_all = "snake_case")]
50pub enum WorkType {
51    Maintenance,  // Entretien régulier
52    Repair,       // Réparation
53    Renovation,   // Rénovation
54    Emergency,    // Intervention d'urgence
55    Inspection,   // Inspection avec travaux
56    Installation, // Installation nouvel équipement
57    Other,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
61#[serde(rename_all = "snake_case")]
62pub enum WarrantyType {
63    None,                  // Pas de garantie
64    Standard,              // 2 ans (vices apparents)
65    Decennial,             // 10 ans (garantie décennale)
66    Extended,              // Garantie étendue (matériel)
67    Custom { years: i32 }, // Garantie personnalisée
68}
69
70/// Erreurs de validation du domaine `WorkReport`.
71///
72/// Type domaine pur — aucune dépendance infra/application (pureté hexagonale).
73/// Précédent `CallForFundsError` / `OwnerContributionError` → 400 validation,
74/// jamais 500 Internal.
75#[derive(Debug, Clone, PartialEq)]
76pub enum WorkReportError {
77    /// Coût de travaux strictement négatif.
78    NegativeCost,
79}
80
81impl std::fmt::Display for WorkReportError {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            Self::NegativeCost => write!(f, "Work report cost cannot be negative"),
85        }
86    }
87}
88
89impl std::error::Error for WorkReportError {}
90
91/// Bridge : use-cases/ports `Result<_, String>` inchangés (cascade
92/// String→AppError = slice large différée, précédent WP-A3/A4/A5).
93impl From<WorkReportError> for String {
94    fn from(e: WorkReportError) -> String {
95        e.to_string()
96    }
97}
98
99impl WorkReport {
100    #[allow(clippy::too_many_arguments)]
101    pub fn new(
102        organization_id: Uuid,
103        building_id: Uuid,
104        title: String,
105        description: String,
106        work_type: WorkType,
107        contractor_name: String,
108        work_date: DateTime<Utc>,
109        cost: Decimal,
110        warranty_type: WarrantyType,
111    ) -> Result<Self, WorkReportError> {
112        // Reprise de l'invariant que portait `#[validate(range(min = 0.0))]`
113        // côté DTO avant cette conversion : `validator` ne sait pas borner un
114        // `Decimal`, la règle descend donc dans le domaine plutôt que de
115        // disparaître. Elle s'applique désormais à tous les appelants, pas
116        // seulement à la route HTTP — précédent `PaymentReminder::new`.
117        if cost < Decimal::ZERO {
118            return Err(WorkReportError::NegativeCost);
119        }
120
121        let now = Utc::now();
122
123        // Calculate warranty expiry based on type
124        let warranty_expiry = match warranty_type {
125            WarrantyType::None => now, // No warranty
126            WarrantyType::Standard => work_date + chrono::Duration::days(2 * 365), // 2 years
127            WarrantyType::Decennial => work_date + chrono::Duration::days(10 * 365), // 10 years
128            WarrantyType::Extended => work_date + chrono::Duration::days(3 * 365), // 3 years default
129            WarrantyType::Custom { years } => {
130                work_date + chrono::Duration::days(years as i64 * 365)
131            }
132        };
133
134        Ok(Self {
135            id: Uuid::new_v4(),
136            organization_id,
137            building_id,
138            title,
139            description,
140            work_type,
141            contractor_name,
142            contractor_contact: None,
143            work_date,
144            completion_date: None,
145            cost,
146            invoice_number: None,
147            photos: Vec::new(),
148            documents: Vec::new(),
149            notes: None,
150            warranty_type,
151            warranty_expiry,
152            created_at: now,
153            updated_at: now,
154        })
155    }
156
157    /// Modifie le coût en portant l'invariant de non-négativité.
158    ///
159    /// Le chemin de mise à jour écrivait `work_report.cost = cost` directement :
160    /// l'invariant du constructeur ne s'y appliquait pas. Il s'applique
161    /// désormais aux deux points d'écriture.
162    pub fn set_cost(&mut self, cost: Decimal) -> Result<(), WorkReportError> {
163        if cost < Decimal::ZERO {
164            return Err(WorkReportError::NegativeCost);
165        }
166        self.cost = cost;
167        self.updated_at = Utc::now();
168        Ok(())
169    }
170
171    /// Check if warranty is still valid
172    pub fn is_warranty_valid(&self) -> bool {
173        Utc::now() < self.warranty_expiry
174    }
175
176    /// Get remaining warranty days
177    pub fn warranty_days_remaining(&self) -> i64 {
178        let now = Utc::now();
179        if now >= self.warranty_expiry {
180            0
181        } else {
182            (self.warranty_expiry - now).num_days()
183        }
184    }
185
186    /// Add photo to work report
187    pub fn add_photo(&mut self, photo_path: String) {
188        self.photos.push(photo_path);
189        self.updated_at = Utc::now();
190    }
191
192    /// Add document to work report
193    pub fn add_document(&mut self, document_path: String) {
194        self.documents.push(document_path);
195        self.updated_at = Utc::now();
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use rust_decimal_macros::dec;
203
204    fn make(cost: Decimal, warranty: WarrantyType) -> Result<WorkReport, WorkReportError> {
205        WorkReport::new(
206            Uuid::new_v4(),
207            Uuid::new_v4(),
208            "Réparation ascenseur".to_string(),
209            "Remplacement câble principal".to_string(),
210            WorkType::Repair,
211            "Schindler Belgium".to_string(),
212            Utc::now(),
213            cost,
214            warranty,
215        )
216    }
217
218    // ----- @happy ---------------------------------------------------------
219
220    #[test]
221    fn happy_work_report_creation() {
222        let report = make(dec!(1500.00), WarrantyType::Standard).expect("coût valide");
223
224        assert_eq!(report.title, "Réparation ascenseur");
225        assert_eq!(report.cost, dec!(1500.00));
226        assert!(report.is_warranty_valid());
227        assert!(report.warranty_days_remaining() > 700); // ~2 ans
228    }
229
230    #[test]
231    fn happy_decennial_warranty() {
232        let report = WorkReport::new(
233            Uuid::new_v4(),
234            Uuid::new_v4(),
235            "Rénovation façade".to_string(),
236            "Réfection complète façade".to_string(),
237            WorkType::Renovation,
238            "BatiPro SPRL".to_string(),
239            Utc::now(),
240            dec!(50000.00),
241            WarrantyType::Decennial,
242        )
243        .expect("coût valide");
244
245        assert!(report.warranty_days_remaining() > 3600); // ~10 ans
246    }
247
248    #[test]
249    fn happy_add_photos() {
250        let mut report = make(dec!(100.00), WarrantyType::None).expect("coût valide");
251
252        report.add_photo("/uploads/photo1.jpg".to_string());
253        report.add_photo("/uploads/photo2.jpg".to_string());
254
255        assert_eq!(report.photos.len(), 2);
256    }
257
258    #[test]
259    fn happy_set_cost_replaces_the_amount() {
260        let mut report = make(dec!(100.00), WarrantyType::None).expect("coût valide");
261
262        report.set_cost(dec!(250.75)).expect("coût valide");
263
264        assert_eq!(report.cost, dec!(250.75));
265    }
266
267    // ----- @edge ----------------------------------------------------------
268
269    /// Borne inférieure : zéro est accepté (travaux sous garantie, refacturés
270    /// à zéro), seul le strictement négatif est refusé.
271    #[test]
272    fn edge_zero_cost_is_accepted() {
273        let report = make(Decimal::ZERO, WarrantyType::None).expect("zéro est un coût valide");
274        assert_eq!(report.cost, Decimal::ZERO);
275    }
276
277    #[test]
278    fn edge_set_cost_to_zero_is_accepted() {
279        let mut report = make(dec!(10.00), WarrantyType::None).expect("coût valide");
280        report
281            .set_cost(Decimal::ZERO)
282            .expect("zéro est un coût valide");
283        assert_eq!(report.cost, Decimal::ZERO);
284    }
285
286    /// Le centime le plus proche de zéro par le bas reste refusé — la borne
287    /// est bien à zéro exclu du côté négatif, pas « autour de zéro ».
288    #[test]
289    fn edge_minus_one_cent_is_rejected() {
290        assert_eq!(
291            make(dec!(-0.01), WarrantyType::None).unwrap_err(),
292            WorkReportError::NegativeCost
293        );
294    }
295
296    /// Exactitude décimale : c'est la raison d'être de la conversion. En
297    /// binary64 cette égalité est fausse.
298    #[test]
299    fn edge_decimal_arithmetic_is_exact() {
300        let mut report = make(dec!(0.10), WarrantyType::None).expect("coût valide");
301        report
302            .set_cost(report.cost + dec!(0.20))
303            .expect("coût valide");
304
305        assert_eq!(report.cost, dec!(0.30));
306    }
307
308    // ----- @negative ------------------------------------------------------
309
310    #[test]
311    fn negative_new_rejects_negative_cost() {
312        assert_eq!(
313            make(dec!(-1.00), WarrantyType::Standard).unwrap_err(),
314            WorkReportError::NegativeCost
315        );
316    }
317
318    #[test]
319    fn negative_set_cost_rejects_negative_cost() {
320        let mut report = make(dec!(100.00), WarrantyType::None).expect("coût valide");
321
322        assert_eq!(
323            report.set_cost(dec!(-0.01)).unwrap_err(),
324            WorkReportError::NegativeCost
325        );
326    }
327
328    /// Un `set_cost` refusé ne doit rien modifier — pas d'écriture partielle.
329    #[test]
330    fn negative_rejected_set_cost_leaves_the_entity_untouched() {
331        let mut report = make(dec!(100.00), WarrantyType::None).expect("coût valide");
332        let before = report.updated_at;
333
334        let _ = report.set_cost(dec!(-5.00));
335
336        assert_eq!(report.cost, dec!(100.00));
337        assert_eq!(report.updated_at, before);
338    }
339
340    // ----- @security ------------------------------------------------------
341
342    /// Un coût négatif est un vecteur d'abus : refacturé via la répartition
343    /// des charges (Art. 3.86 CC), il produirait un AVOIR au profit des
344    /// copropriétaires depuis un simple rapport de travaux. L'invariant est
345    /// porté par le domaine, donc inaccessible en contournant la route HTTP.
346    #[test]
347    fn security_negative_cost_cannot_bypass_the_domain() {
348        assert!(make(dec!(-999999.99), WarrantyType::Decennial).is_err());
349
350        let mut report = make(dec!(1.00), WarrantyType::None).expect("coût valide");
351        assert!(report.set_cost(dec!(-999999.99)).is_err());
352        assert_eq!(report.cost, dec!(1.00));
353    }
354}