koprogo_api/domain/entities/
board_decision.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Statut d'une décision du conseil de copropriété
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum DecisionStatus {
8    Pending,    // En attente d'exécution
9    InProgress, // En cours d'exécution
10    Completed,  // Terminée
11    Overdue,    // En retard (deadline dépassée)
12    Cancelled,  // Annulée
13}
14
15impl std::fmt::Display for DecisionStatus {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            DecisionStatus::Pending => write!(f, "pending"),
19            DecisionStatus::InProgress => write!(f, "in_progress"),
20            DecisionStatus::Completed => write!(f, "completed"),
21            DecisionStatus::Overdue => write!(f, "overdue"),
22            DecisionStatus::Cancelled => write!(f, "cancelled"),
23        }
24    }
25}
26
27impl std::str::FromStr for DecisionStatus {
28    type Err = String;
29
30    fn from_str(s: &str) -> Result<Self, Self::Err> {
31        match s.to_lowercase().as_str() {
32            "pending" => Ok(DecisionStatus::Pending),
33            "in_progress" => Ok(DecisionStatus::InProgress),
34            "completed" => Ok(DecisionStatus::Completed),
35            "overdue" => Ok(DecisionStatus::Overdue),
36            "cancelled" => Ok(DecisionStatus::Cancelled),
37            _ => Err(format!("Invalid decision status: {}", s)),
38        }
39    }
40}
41
42/// Décision prise par l'assemblée générale et suivie par le conseil de copropriété
43/// Le conseil surveille l'exécution par le syndic des décisions votées en AG
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct BoardDecision {
46    pub id: Uuid,
47    pub building_id: Uuid,
48    pub meeting_id: Uuid,                // AG qui a pris la décision
49    pub subject: String,                 // Objet de la décision
50    pub decision_text: String,           // Texte complet de la décision
51    pub deadline: Option<DateTime<Utc>>, // Date limite d'exécution
52    pub status: DecisionStatus,
53    pub completed_at: Option<DateTime<Utc>>,
54    pub notes: Option<String>, // Notes du conseil sur le suivi
55    pub created_at: DateTime<Utc>,
56    pub updated_at: DateTime<Utc>,
57}
58
59impl BoardDecision {
60    /// Crée une nouvelle décision à suivre
61    pub fn new(
62        building_id: Uuid,
63        meeting_id: Uuid,
64        subject: String,
65        decision_text: String,
66        deadline: Option<DateTime<Utc>>,
67    ) -> Result<Self, String> {
68        // Validation: subject ne peut pas être vide
69        if subject.trim().is_empty() {
70            return Err("Decision subject cannot be empty".to_string());
71        }
72
73        // Validation: decision_text ne peut pas être vide
74        if decision_text.trim().is_empty() {
75            return Err("Decision text cannot be empty".to_string());
76        }
77
78        // Validation: si deadline existe, elle doit être dans le futur
79        if let Some(deadline_date) = deadline {
80            if deadline_date <= Utc::now() {
81                return Err("Deadline must be in the future".to_string());
82            }
83        }
84
85        let now = Utc::now();
86        Ok(Self {
87            id: Uuid::new_v4(),
88            building_id,
89            meeting_id,
90            subject,
91            decision_text,
92            deadline,
93            status: DecisionStatus::Pending,
94            completed_at: None,
95            notes: None,
96            created_at: now,
97            updated_at: now,
98        })
99    }
100
101    /// Vérifie si la décision est en retard
102    pub fn is_overdue(&self) -> bool {
103        if self.status == DecisionStatus::Completed || self.status == DecisionStatus::Cancelled {
104            return false;
105        }
106
107        if let Some(deadline) = self.deadline {
108            Utc::now() > deadline
109        } else {
110            false
111        }
112    }
113
114    /// Met à jour le statut de la décision
115    /// Gère automatiquement les transitions valides et le timestamp completed_at
116    pub fn update_status(&mut self, new_status: DecisionStatus) -> Result<(), String> {
117        // Validation des transitions de statut
118        match (&self.status, &new_status) {
119            // On ne peut pas modifier une décision terminée ou annulée
120            (DecisionStatus::Completed, _) => {
121                return Err("Cannot change status of a completed decision".to_string());
122            }
123            (DecisionStatus::Cancelled, _) => {
124                return Err("Cannot change status of a cancelled decision".to_string());
125            }
126            // Transitions valides
127            (DecisionStatus::Pending, DecisionStatus::InProgress)
128            | (DecisionStatus::Pending, DecisionStatus::Cancelled)
129            | (DecisionStatus::InProgress, DecisionStatus::Completed)
130            | (DecisionStatus::InProgress, DecisionStatus::Cancelled)
131            | (DecisionStatus::Overdue, DecisionStatus::InProgress)
132            | (DecisionStatus::Overdue, DecisionStatus::Completed)
133            | (DecisionStatus::Overdue, DecisionStatus::Cancelled) => {}
134            // Transition invalide
135            _ => {
136                return Err(format!(
137                    "Invalid status transition from {} to {}",
138                    self.status, new_status
139                ));
140            }
141        }
142
143        self.status = new_status.clone();
144        self.updated_at = Utc::now();
145
146        // Si on marque comme terminé, enregistrer la date
147        if new_status == DecisionStatus::Completed {
148            self.completed_at = Some(Utc::now());
149        }
150
151        Ok(())
152    }
153
154    /// Ajoute ou met à jour les notes de suivi
155    pub fn add_notes(&mut self, notes: String) {
156        self.notes = Some(notes);
157        self.updated_at = Utc::now();
158    }
159
160    /// Vérifie le statut actuel et met à jour automatiquement si en retard
161    pub fn check_and_update_overdue_status(&mut self) {
162        if self.is_overdue() && self.status != DecisionStatus::Overdue {
163            self.status = DecisionStatus::Overdue;
164            self.updated_at = Utc::now();
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use chrono::Duration;
173
174    #[test]
175    fn test_create_decision_success() {
176        // Arrange
177        let building_id = Uuid::new_v4();
178        let meeting_id = Uuid::new_v4();
179        let subject = "Réparation ascenseur".to_string();
180        let text = "Approuver les travaux de réparation de l'ascenseur pour un montant de 15000€"
181            .to_string();
182        let deadline = Some(Utc::now() + Duration::days(60));
183
184        // Act
185        let result = BoardDecision::new(
186            building_id,
187            meeting_id,
188            subject.clone(),
189            text.clone(),
190            deadline,
191        );
192
193        // Assert
194        assert!(result.is_ok());
195        let decision = result.unwrap();
196        assert_eq!(decision.building_id, building_id);
197        assert_eq!(decision.meeting_id, meeting_id);
198        assert_eq!(decision.subject, subject);
199        assert_eq!(decision.decision_text, text);
200        assert_eq!(decision.status, DecisionStatus::Pending);
201        assert!(decision.completed_at.is_none());
202        assert!(decision.deadline.is_some());
203    }
204
205    #[test]
206    fn test_create_decision_empty_subject_fails() {
207        // Arrange
208        let subject = "   ".to_string(); // Seulement des espaces
209
210        // Act
211        let result = BoardDecision::new(
212            Uuid::new_v4(),
213            Uuid::new_v4(),
214            subject,
215            "Some text".to_string(),
216            None,
217        );
218
219        // Assert
220        assert!(result.is_err());
221        assert_eq!(result.unwrap_err(), "Decision subject cannot be empty");
222    }
223
224    #[test]
225    fn test_create_decision_empty_text_fails() {
226        // Arrange
227        let text = "".to_string();
228
229        // Act
230        let result = BoardDecision::new(
231            Uuid::new_v4(),
232            Uuid::new_v4(),
233            "Subject".to_string(),
234            text,
235            None,
236        );
237
238        // Assert
239        assert!(result.is_err());
240        assert_eq!(result.unwrap_err(), "Decision text cannot be empty");
241    }
242
243    #[test]
244    fn test_create_decision_past_deadline_fails() {
245        // Arrange
246        let deadline = Some(Utc::now() - Duration::days(1)); // Hier
247
248        // Act
249        let result = BoardDecision::new(
250            Uuid::new_v4(),
251            Uuid::new_v4(),
252            "Subject".to_string(),
253            "Text".to_string(),
254            deadline,
255        );
256
257        // Assert
258        assert!(result.is_err());
259        assert_eq!(result.unwrap_err(), "Deadline must be in the future");
260    }
261
262    #[test]
263    fn test_is_overdue_true() {
264        // Arrange
265        let deadline = Some(Utc::now() - Duration::days(1)); // Deadline dépassée
266        let decision = BoardDecision {
267            id: Uuid::new_v4(),
268            building_id: Uuid::new_v4(),
269            meeting_id: Uuid::new_v4(),
270            subject: "Test".to_string(),
271            decision_text: "Test text".to_string(),
272            deadline,
273            status: DecisionStatus::Pending,
274            completed_at: None,
275            notes: None,
276            created_at: Utc::now(),
277            updated_at: Utc::now(),
278        };
279
280        // Act & Assert
281        assert!(decision.is_overdue());
282    }
283
284    #[test]
285    fn test_is_overdue_false_no_deadline() {
286        // Arrange
287        let decision = BoardDecision {
288            id: Uuid::new_v4(),
289            building_id: Uuid::new_v4(),
290            meeting_id: Uuid::new_v4(),
291            subject: "Test".to_string(),
292            decision_text: "Test text".to_string(),
293            deadline: None, // Pas de deadline
294            status: DecisionStatus::Pending,
295            completed_at: None,
296            notes: None,
297            created_at: Utc::now(),
298            updated_at: Utc::now(),
299        };
300
301        // Act & Assert
302        assert!(!decision.is_overdue());
303    }
304
305    #[test]
306    fn test_is_overdue_false_completed() {
307        // Arrange
308        let deadline = Some(Utc::now() - Duration::days(1)); // Deadline dépassée mais complété
309        let decision = BoardDecision {
310            id: Uuid::new_v4(),
311            building_id: Uuid::new_v4(),
312            meeting_id: Uuid::new_v4(),
313            subject: "Test".to_string(),
314            decision_text: "Test text".to_string(),
315            deadline,
316            status: DecisionStatus::Completed,
317            completed_at: Some(Utc::now() - Duration::hours(2)),
318            notes: None,
319            created_at: Utc::now(),
320            updated_at: Utc::now(),
321        };
322
323        // Act & Assert
324        assert!(!decision.is_overdue()); // Pas overdue car déjà complété
325    }
326
327    #[test]
328    fn test_update_status_valid_transitions() {
329        // Arrange
330        let mut decision = BoardDecision::new(
331            Uuid::new_v4(),
332            Uuid::new_v4(),
333            "Test".to_string(),
334            "Text".to_string(),
335            Some(Utc::now() + Duration::days(30)),
336        )
337        .unwrap();
338
339        // Act & Assert: Pending -> InProgress
340        assert!(decision.update_status(DecisionStatus::InProgress).is_ok());
341        assert_eq!(decision.status, DecisionStatus::InProgress);
342
343        // Act & Assert: InProgress -> Completed
344        assert!(decision.update_status(DecisionStatus::Completed).is_ok());
345        assert_eq!(decision.status, DecisionStatus::Completed);
346        assert!(decision.completed_at.is_some());
347    }
348
349    #[test]
350    fn test_update_status_cannot_modify_completed() {
351        // Arrange
352        let mut decision = BoardDecision::new(
353            Uuid::new_v4(),
354            Uuid::new_v4(),
355            "Test".to_string(),
356            "Text".to_string(),
357            None,
358        )
359        .unwrap();
360        decision.update_status(DecisionStatus::InProgress).unwrap();
361        decision.update_status(DecisionStatus::Completed).unwrap();
362
363        // Act
364        let result = decision.update_status(DecisionStatus::Pending);
365
366        // Assert
367        assert!(result.is_err());
368        assert_eq!(
369            result.unwrap_err(),
370            "Cannot change status of a completed decision"
371        );
372    }
373
374    #[test]
375    fn test_update_status_invalid_transition() {
376        // Arrange
377        let mut decision = BoardDecision::new(
378            Uuid::new_v4(),
379            Uuid::new_v4(),
380            "Test".to_string(),
381            "Text".to_string(),
382            None,
383        )
384        .unwrap();
385
386        // Act: Essayer Pending -> Completed (doit passer par InProgress)
387        let result = decision.update_status(DecisionStatus::Completed);
388
389        // Assert
390        assert!(result.is_err());
391        assert!(result.unwrap_err().contains("Invalid status transition"));
392    }
393
394    #[test]
395    fn test_add_notes() {
396        // Arrange
397        let mut decision = BoardDecision::new(
398            Uuid::new_v4(),
399            Uuid::new_v4(),
400            "Test".to_string(),
401            "Text".to_string(),
402            None,
403        )
404        .unwrap();
405
406        // Act
407        decision.add_notes("Le syndic a confirmé le début des travaux".to_string());
408
409        // Assert
410        assert!(decision.notes.is_some());
411        assert_eq!(
412            decision.notes.unwrap(),
413            "Le syndic a confirmé le début des travaux"
414        );
415    }
416
417    #[test]
418    fn test_check_and_update_overdue_status() {
419        // Arrange
420        let deadline = Some(Utc::now() - Duration::days(1)); // Déjà dépassée
421        let mut decision = BoardDecision {
422            id: Uuid::new_v4(),
423            building_id: Uuid::new_v4(),
424            meeting_id: Uuid::new_v4(),
425            subject: "Test".to_string(),
426            decision_text: "Test text".to_string(),
427            deadline,
428            status: DecisionStatus::Pending,
429            completed_at: None,
430            notes: None,
431            created_at: Utc::now(),
432            updated_at: Utc::now(),
433        };
434
435        // Act
436        decision.check_and_update_overdue_status();
437
438        // Assert
439        assert_eq!(decision.status, DecisionStatus::Overdue);
440    }
441
442    #[test]
443    fn test_decision_status_display() {
444        assert_eq!(DecisionStatus::Pending.to_string(), "pending");
445        assert_eq!(DecisionStatus::InProgress.to_string(), "in_progress");
446        assert_eq!(DecisionStatus::Completed.to_string(), "completed");
447        assert_eq!(DecisionStatus::Overdue.to_string(), "overdue");
448        assert_eq!(DecisionStatus::Cancelled.to_string(), "cancelled");
449    }
450
451    #[test]
452    fn test_decision_status_from_str() {
453        assert_eq!(
454            "pending".parse::<DecisionStatus>().unwrap(),
455            DecisionStatus::Pending
456        );
457        assert_eq!(
458            "COMPLETED".parse::<DecisionStatus>().unwrap(),
459            DecisionStatus::Completed
460        );
461        assert_eq!(
462            "in_progress".parse::<DecisionStatus>().unwrap(),
463            DecisionStatus::InProgress
464        );
465
466        assert!("invalid".parse::<DecisionStatus>().is_err());
467    }
468}