koprogo_api/domain/entities/
contractor_report.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Statut d'un rapport de travaux corps de métier
6///
7/// Machine d'état BC16 (Backoffice Prestataires PWA) :
8/// Draft → Submitted → UnderReview → Validated/Rejected/RequiresCorrection
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub enum ContractorReportStatus {
11    /// Corps de métier rédige le rapport (photos, pièces, compte-rendu)
12    Draft,
13    /// Soumis au conseil de copropriété pour validation
14    Submitted,
15    /// CdC examine le rapport
16    UnderReview,
17    /// CdC validé → déclenche le paiement automatique
18    Validated,
19    /// CdC refuse le rapport (avec motif)
20    Rejected,
21    /// CdC demande des corrections (motif + délai)
22    RequiresCorrection,
23}
24
25impl ContractorReportStatus {
26    pub fn from_db_string(s: &str) -> Result<Self, String> {
27        match s {
28            "draft" => Ok(Self::Draft),
29            "submitted" => Ok(Self::Submitted),
30            "under_review" => Ok(Self::UnderReview),
31            "validated" => Ok(Self::Validated),
32            "rejected" => Ok(Self::Rejected),
33            "requires_correction" => Ok(Self::RequiresCorrection),
34            _ => Err(format!("Unknown contractor_report_status: {}", s)),
35        }
36    }
37
38    pub fn to_db_str(&self) -> &'static str {
39        match self {
40            Self::Draft => "draft",
41            Self::Submitted => "submitted",
42            Self::UnderReview => "under_review",
43            Self::Validated => "validated",
44            Self::Rejected => "rejected",
45            Self::RequiresCorrection => "requires_correction",
46        }
47    }
48
49    pub fn is_terminal(&self) -> bool {
50        matches!(self, Self::Validated | Self::Rejected)
51    }
52}
53
54/// Pièce remplacée lors des travaux
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct ReplacedPart {
57    pub name: String,
58    pub reference: Option<String>,
59    pub quantity: u32,
60    pub photo_document_id: Option<Uuid>,
61}
62
63/// Rapport de travaux soumis par le corps de métier via magic link PWA
64///
65/// Workflow :
66/// 1. Ticket/Quote assigné → magic link JWT 72h envoyé au corps de métier
67/// 2. Corps de métier : photos avant/après + pièces + compte-rendu → submit
68/// 3. CdC : valide (→ paiement auto) ou demande corrections ou rejette
69#[derive(Debug, Clone)]
70pub struct ContractorReport {
71    pub id: Uuid,
72    pub organization_id: Uuid,
73    pub building_id: Uuid,
74
75    /// Ticket ou devis associé (au moins l'un des deux doit être présent)
76    pub ticket_id: Option<Uuid>,
77    pub quote_id: Option<Uuid>,
78
79    /// Id du prestataire (service_provider futur) ou nom libre
80    pub contractor_user_id: Option<Uuid>,
81    pub contractor_name: String,
82
83    /// Date d'intervention
84    pub work_date: Option<DateTime<Utc>>,
85
86    /// Compte-rendu libre du corps de métier
87    pub compte_rendu: Option<String>,
88
89    /// Photos avant travaux (document_ids)
90    pub photos_before: Vec<Uuid>,
91    /// Photos après travaux (document_ids)
92    pub photos_after: Vec<Uuid>,
93    /// Pièces remplacées
94    pub parts_replaced: Vec<ReplacedPart>,
95
96    /// Statut de la machine d'état
97    pub status: ContractorReportStatus,
98
99    /// Magic link token (JWT hashé) pour accès sans auth classique
100    pub magic_token_hash: Option<String>,
101    pub magic_token_expires_at: Option<DateTime<Utc>>,
102
103    /// Horodatage de soumission
104    pub submitted_at: Option<DateTime<Utc>>,
105    /// Validation CdC
106    pub validated_at: Option<DateTime<Utc>>,
107    pub validated_by: Option<Uuid>,
108    /// Commentaires CdC (corrections ou refus)
109    pub review_comments: Option<String>,
110
111    pub created_at: DateTime<Utc>,
112    pub updated_at: DateTime<Utc>,
113}
114
115impl ContractorReport {
116    pub fn new(
117        organization_id: Uuid,
118        building_id: Uuid,
119        contractor_name: String,
120        ticket_id: Option<Uuid>,
121        quote_id: Option<Uuid>,
122        contractor_user_id: Option<Uuid>,
123    ) -> Result<Self, String> {
124        if contractor_name.trim().is_empty() {
125            return Err("Le nom du prestataire est obligatoire".to_string());
126        }
127        if ticket_id.is_none() && quote_id.is_none() {
128            return Err("Un rapport doit être lié à un ticket ou à un devis".to_string());
129        }
130        let now = Utc::now();
131        Ok(Self {
132            id: Uuid::new_v4(),
133            organization_id,
134            building_id,
135            ticket_id,
136            quote_id,
137            contractor_user_id,
138            contractor_name,
139            work_date: None,
140            compte_rendu: None,
141            photos_before: vec![],
142            photos_after: vec![],
143            parts_replaced: vec![],
144            status: ContractorReportStatus::Draft,
145            magic_token_hash: None,
146            magic_token_expires_at: None,
147            submitted_at: None,
148            validated_at: None,
149            validated_by: None,
150            review_comments: None,
151            created_at: now,
152            updated_at: now,
153        })
154    }
155
156    /// Corps de métier soumet le rapport (Draft → Submitted)
157    pub fn submit(&mut self) -> Result<(), String> {
158        if self.status != ContractorReportStatus::Draft
159            && self.status != ContractorReportStatus::RequiresCorrection
160        {
161            return Err(format!(
162                "Impossible de soumettre depuis l'état {:?}",
163                self.status
164            ));
165        }
166        if self.compte_rendu.as_deref().unwrap_or("").trim().is_empty() {
167            return Err(
168                "Le champ compte_rendu est obligatoire pour soumettre le rapport".to_string(),
169            );
170        }
171        self.status = ContractorReportStatus::Submitted;
172        self.submitted_at = Some(Utc::now());
173        self.updated_at = Utc::now();
174        Ok(())
175    }
176
177    /// CdC commence l'examen (Submitted → UnderReview)
178    pub fn start_review(&mut self) -> Result<(), String> {
179        if self.status != ContractorReportStatus::Submitted {
180            return Err(format!(
181                "Impossible de mettre en révision depuis l'état {:?}",
182                self.status
183            ));
184        }
185        self.status = ContractorReportStatus::UnderReview;
186        self.updated_at = Utc::now();
187        Ok(())
188    }
189
190    /// CdC valide le rapport (→ Validated, déclenche paiement)
191    pub fn validate(&mut self, validated_by: Uuid) -> Result<(), String> {
192        if self.status != ContractorReportStatus::Submitted
193            && self.status != ContractorReportStatus::UnderReview
194        {
195            return Err(format!(
196                "Impossible de valider depuis l'état {:?}",
197                self.status
198            ));
199        }
200        self.status = ContractorReportStatus::Validated;
201        self.validated_at = Some(Utc::now());
202        self.validated_by = Some(validated_by);
203        self.updated_at = Utc::now();
204        Ok(())
205    }
206
207    /// CdC demande des corrections (→ RequiresCorrection)
208    pub fn request_corrections(&mut self, comments: String) -> Result<(), String> {
209        if self.status != ContractorReportStatus::Submitted
210            && self.status != ContractorReportStatus::UnderReview
211        {
212            return Err(format!(
213                "Impossible de demander des corrections depuis l'état {:?}",
214                self.status
215            ));
216        }
217        if comments.trim().is_empty() {
218            return Err("Les commentaires de correction sont obligatoires".to_string());
219        }
220        self.status = ContractorReportStatus::RequiresCorrection;
221        self.review_comments = Some(comments);
222        self.updated_at = Utc::now();
223        Ok(())
224    }
225
226    /// CdC rejette le rapport
227    pub fn reject(&mut self, comments: String, rejected_by: Uuid) -> Result<(), String> {
228        if self.status.is_terminal() {
229            return Err(format!(
230                "Impossible de rejeter depuis l'état terminal {:?}",
231                self.status
232            ));
233        }
234        self.status = ContractorReportStatus::Rejected;
235        self.review_comments = Some(comments);
236        self.validated_by = Some(rejected_by);
237        self.updated_at = Utc::now();
238        Ok(())
239    }
240
241    /// Vérifie si le magic token est encore valide
242    pub fn is_magic_token_valid(&self) -> bool {
243        match &self.magic_token_expires_at {
244            Some(exp) => *exp > Utc::now(),
245            None => false,
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn make_report() -> ContractorReport {
255        let mut r = ContractorReport::new(
256            Uuid::new_v4(),
257            Uuid::new_v4(),
258            "Martin Plomberie SPRL".to_string(),
259            Some(Uuid::new_v4()),
260            None,
261            None,
262        )
263        .unwrap();
264        r.compte_rendu = Some("Travaux effectués conformément au devis".to_string());
265        r
266    }
267
268    #[test]
269    fn test_new_report_success() {
270        let r = make_report();
271        assert_eq!(r.status, ContractorReportStatus::Draft);
272        assert!(r.photos_before.is_empty());
273    }
274
275    #[test]
276    fn test_new_requires_ticket_or_quote() {
277        let err = ContractorReport::new(
278            Uuid::new_v4(),
279            Uuid::new_v4(),
280            "Test".to_string(),
281            None,
282            None,
283            None,
284        );
285        assert!(err.is_err());
286    }
287
288    #[test]
289    fn test_new_requires_contractor_name() {
290        let err = ContractorReport::new(
291            Uuid::new_v4(),
292            Uuid::new_v4(),
293            "  ".to_string(),
294            Some(Uuid::new_v4()),
295            None,
296            None,
297        );
298        assert!(err.is_err());
299    }
300
301    #[test]
302    fn test_submit_from_draft() {
303        let mut r = make_report();
304        r.submit().unwrap();
305        assert_eq!(r.status, ContractorReportStatus::Submitted);
306        assert!(r.submitted_at.is_some());
307    }
308
309    #[test]
310    fn test_submit_from_requires_correction() {
311        let mut r = make_report();
312        r.submit().unwrap();
313        r.request_corrections("Manque photos avant".to_string())
314            .unwrap();
315        r.submit().unwrap();
316        assert_eq!(r.status, ContractorReportStatus::Submitted);
317    }
318
319    #[test]
320    fn test_validate_from_submitted() {
321        let mut r = make_report();
322        let cdc_id = Uuid::new_v4();
323        r.submit().unwrap();
324        r.validate(cdc_id).unwrap();
325        assert_eq!(r.status, ContractorReportStatus::Validated);
326        assert_eq!(r.validated_by, Some(cdc_id));
327    }
328
329    #[test]
330    fn test_validate_from_under_review() {
331        let mut r = make_report();
332        let cdc_id = Uuid::new_v4();
333        r.submit().unwrap();
334        r.start_review().unwrap();
335        r.validate(cdc_id).unwrap();
336        assert_eq!(r.status, ContractorReportStatus::Validated);
337    }
338
339    #[test]
340    fn test_cannot_validate_from_draft() {
341        let mut r = make_report();
342        assert!(r.validate(Uuid::new_v4()).is_err());
343    }
344
345    #[test]
346    fn test_request_corrections_requires_comment() {
347        let mut r = make_report();
348        r.submit().unwrap();
349        assert!(r.request_corrections("  ".to_string()).is_err());
350    }
351
352    #[test]
353    fn test_request_corrections_ok() {
354        let mut r = make_report();
355        r.submit().unwrap();
356        r.request_corrections("Ajoutez les photos après travaux".to_string())
357            .unwrap();
358        assert_eq!(r.status, ContractorReportStatus::RequiresCorrection);
359        assert!(r.review_comments.is_some());
360    }
361
362    #[test]
363    fn test_reject_from_submitted() {
364        let mut r = make_report();
365        r.submit().unwrap();
366        r.reject("Travaux non conformes".to_string(), Uuid::new_v4())
367            .unwrap();
368        assert_eq!(r.status, ContractorReportStatus::Rejected);
369    }
370
371    #[test]
372    fn test_cannot_reject_validated() {
373        let mut r = make_report();
374        r.submit().unwrap();
375        r.validate(Uuid::new_v4()).unwrap();
376        assert!(r.reject("Non".to_string(), Uuid::new_v4()).is_err());
377    }
378
379    #[test]
380    fn test_magic_token_expired() {
381        let mut r = make_report();
382        r.magic_token_expires_at = Some(Utc::now() - chrono::Duration::hours(1));
383        assert!(!r.is_magic_token_valid());
384    }
385
386    #[test]
387    fn test_status_db_roundtrip() {
388        let statuses = [
389            ContractorReportStatus::Draft,
390            ContractorReportStatus::Submitted,
391            ContractorReportStatus::UnderReview,
392            ContractorReportStatus::Validated,
393            ContractorReportStatus::Rejected,
394            ContractorReportStatus::RequiresCorrection,
395        ];
396        for s in &statuses {
397            let db = s.to_db_str();
398            let back = ContractorReportStatus::from_db_string(db).unwrap();
399            assert_eq!(s, &back);
400        }
401    }
402}