Skip to main content

koprogo_api/application/use_cases/
work_report_use_cases.rs

1use crate::application::dto::{
2    AddDocumentDto, AddPhotoDto, CreateWorkReportDto, PageRequest, UpdateWorkReportDto,
3    WarrantyStatusDto, WorkReportFilters, WorkReportListResponseDto, WorkReportResponseDto,
4};
5use crate::application::ports::WorkReportRepository;
6use crate::domain::entities::WorkReport;
7use chrono::DateTime;
8use std::sync::Arc;
9use uuid::Uuid;
10
11pub struct WorkReportUseCases {
12    repository: Arc<dyn WorkReportRepository>,
13}
14
15impl WorkReportUseCases {
16    pub fn new(repository: Arc<dyn WorkReportRepository>) -> Self {
17        Self { repository }
18    }
19
20    pub async fn create_work_report(
21        &self,
22        dto: CreateWorkReportDto,
23    ) -> Result<WorkReportResponseDto, String> {
24        let organization_id = Uuid::parse_str(&dto.organization_id)
25            .map_err(|_| "Invalid organization_id format".to_string())?;
26        let building_id = Uuid::parse_str(&dto.building_id)
27            .map_err(|_| "Invalid building_id format".to_string())?;
28
29        let work_date = DateTime::parse_from_rfc3339(&dto.work_date)
30            .map_err(|_| "Invalid work_date format".to_string())?
31            .with_timezone(&chrono::Utc);
32
33        let completion_date = if let Some(ref date_str) = dto.completion_date {
34            Some(
35                DateTime::parse_from_rfc3339(date_str)
36                    .map_err(|_| "Invalid completion_date format".to_string())?
37                    .with_timezone(&chrono::Utc),
38            )
39        } else {
40            None
41        };
42
43        let work_report = WorkReport::new(
44            organization_id,
45            building_id,
46            dto.title,
47            dto.description,
48            dto.work_type,
49            dto.contractor_name,
50            work_date,
51            dto.cost,
52            dto.warranty_type.clone(),
53        )?;
54
55        let mut work_report = work_report;
56        work_report.contractor_contact = dto.contractor_contact;
57        work_report.completion_date = completion_date;
58        work_report.invoice_number = dto.invoice_number;
59        work_report.notes = dto.notes;
60
61        let created = self.repository.create(&work_report).await?;
62        Ok(self.to_response_dto(&created))
63    }
64
65    pub async fn get_work_report(&self, id: Uuid) -> Result<Option<WorkReportResponseDto>, String> {
66        let work_report = self.repository.find_by_id(id).await?;
67        Ok(work_report.map(|w| self.to_response_dto(&w)))
68    }
69
70    pub async fn list_work_reports_by_building(
71        &self,
72        building_id: Uuid,
73    ) -> Result<Vec<WorkReportResponseDto>, String> {
74        let work_reports = self.repository.find_by_building(building_id).await?;
75        Ok(work_reports
76            .iter()
77            .map(|w| self.to_response_dto(w))
78            .collect())
79    }
80
81    pub async fn list_work_reports_by_organization(
82        &self,
83        organization_id: Uuid,
84    ) -> Result<Vec<WorkReportResponseDto>, String> {
85        let work_reports = self
86            .repository
87            .find_by_organization(organization_id)
88            .await?;
89        Ok(work_reports
90            .iter()
91            .map(|w| self.to_response_dto(w))
92            .collect())
93    }
94
95    pub async fn list_work_reports_paginated(
96        &self,
97        page_request: &PageRequest,
98        filters: &WorkReportFilters,
99    ) -> Result<WorkReportListResponseDto, String> {
100        let (work_reports, total) = self
101            .repository
102            .find_all_paginated(page_request, filters)
103            .await?;
104
105        let dtos = work_reports
106            .iter()
107            .map(|w| self.to_response_dto(w))
108            .collect();
109
110        Ok(WorkReportListResponseDto {
111            work_reports: dtos,
112            total,
113            page: page_request.page,
114            page_size: page_request.per_page,
115        })
116    }
117
118    pub async fn get_active_warranties(
119        &self,
120        building_id: Uuid,
121    ) -> Result<Vec<WarrantyStatusDto>, String> {
122        let work_reports = self
123            .repository
124            .find_with_active_warranty(building_id)
125            .await?;
126
127        Ok(work_reports
128            .iter()
129            .map(|w| WarrantyStatusDto {
130                work_report_id: w.id.to_string(),
131                title: w.title.clone(),
132                warranty_type: w.warranty_type.clone(),
133                warranty_expiry: w.warranty_expiry.to_rfc3339(),
134                is_valid: w.is_warranty_valid(),
135                days_remaining: w.warranty_days_remaining(),
136            })
137            .collect())
138    }
139
140    pub async fn get_expiring_warranties(
141        &self,
142        building_id: Uuid,
143        days: i32,
144    ) -> Result<Vec<WarrantyStatusDto>, String> {
145        let work_reports = self
146            .repository
147            .find_with_expiring_warranty(building_id, days)
148            .await?;
149
150        Ok(work_reports
151            .iter()
152            .map(|w| WarrantyStatusDto {
153                work_report_id: w.id.to_string(),
154                title: w.title.clone(),
155                warranty_type: w.warranty_type.clone(),
156                warranty_expiry: w.warranty_expiry.to_rfc3339(),
157                is_valid: w.is_warranty_valid(),
158                days_remaining: w.warranty_days_remaining(),
159            })
160            .collect())
161    }
162
163    pub async fn update_work_report(
164        &self,
165        id: Uuid,
166        dto: UpdateWorkReportDto,
167    ) -> Result<WorkReportResponseDto, String> {
168        let mut work_report = self
169            .repository
170            .find_by_id(id)
171            .await?
172            .ok_or_else(|| "Work report not found".to_string())?;
173
174        if let Some(title) = dto.title {
175            work_report.title = title;
176        }
177        if let Some(description) = dto.description {
178            work_report.description = description;
179        }
180        if let Some(work_type) = dto.work_type {
181            work_report.work_type = work_type;
182        }
183        if let Some(contractor_name) = dto.contractor_name {
184            work_report.contractor_name = contractor_name;
185        }
186        if let Some(contractor_contact) = dto.contractor_contact {
187            work_report.contractor_contact = Some(contractor_contact);
188        }
189        if let Some(work_date_str) = dto.work_date {
190            let work_date = DateTime::parse_from_rfc3339(&work_date_str)
191                .map_err(|_| "Invalid work_date format".to_string())?
192                .with_timezone(&chrono::Utc);
193            work_report.work_date = work_date;
194        }
195        if let Some(completion_date_str) = dto.completion_date {
196            let completion_date = DateTime::parse_from_rfc3339(&completion_date_str)
197                .map_err(|_| "Invalid completion_date format".to_string())?
198                .with_timezone(&chrono::Utc);
199            work_report.completion_date = Some(completion_date);
200        }
201        if let Some(cost) = dto.cost {
202            work_report.set_cost(cost)?;
203        }
204        if let Some(invoice_number) = dto.invoice_number {
205            work_report.invoice_number = Some(invoice_number);
206        }
207        if let Some(notes) = dto.notes {
208            work_report.notes = Some(notes);
209        }
210        if let Some(warranty_type) = dto.warranty_type {
211            work_report.warranty_type = warranty_type;
212            // Recalculate warranty expiry when type changes
213            work_report.warranty_expiry = match work_report.warranty_type {
214                crate::domain::entities::WarrantyType::None => chrono::Utc::now(),
215                crate::domain::entities::WarrantyType::Standard => {
216                    work_report.work_date + chrono::Duration::days(2 * 365)
217                }
218                crate::domain::entities::WarrantyType::Decennial => {
219                    work_report.work_date + chrono::Duration::days(10 * 365)
220                }
221                crate::domain::entities::WarrantyType::Extended => {
222                    work_report.work_date + chrono::Duration::days(3 * 365)
223                }
224                crate::domain::entities::WarrantyType::Custom { years } => {
225                    work_report.work_date + chrono::Duration::days(years as i64 * 365)
226                }
227            };
228        }
229
230        work_report.updated_at = chrono::Utc::now();
231
232        let updated = self.repository.update(&work_report).await?;
233        Ok(self.to_response_dto(&updated))
234    }
235
236    pub async fn add_photo(
237        &self,
238        id: Uuid,
239        dto: AddPhotoDto,
240    ) -> Result<WorkReportResponseDto, String> {
241        let mut work_report = self
242            .repository
243            .find_by_id(id)
244            .await?
245            .ok_or_else(|| "Work report not found".to_string())?;
246
247        work_report.add_photo(dto.photo_path);
248
249        let updated = self.repository.update(&work_report).await?;
250        Ok(self.to_response_dto(&updated))
251    }
252
253    pub async fn add_document(
254        &self,
255        id: Uuid,
256        dto: AddDocumentDto,
257    ) -> Result<WorkReportResponseDto, String> {
258        let mut work_report = self
259            .repository
260            .find_by_id(id)
261            .await?
262            .ok_or_else(|| "Work report not found".to_string())?;
263
264        work_report.add_document(dto.document_path);
265
266        let updated = self.repository.update(&work_report).await?;
267        Ok(self.to_response_dto(&updated))
268    }
269
270    pub async fn delete_work_report(&self, id: Uuid) -> Result<bool, String> {
271        self.repository.delete(id).await
272    }
273
274    fn to_response_dto(&self, work_report: &WorkReport) -> WorkReportResponseDto {
275        WorkReportResponseDto {
276            id: work_report.id.to_string(),
277            organization_id: work_report.organization_id.to_string(),
278            building_id: work_report.building_id.to_string(),
279            title: work_report.title.clone(),
280            description: work_report.description.clone(),
281            work_type: work_report.work_type.clone(),
282            contractor_name: work_report.contractor_name.clone(),
283            contractor_contact: work_report.contractor_contact.clone(),
284            work_date: work_report.work_date.to_rfc3339(),
285            completion_date: work_report.completion_date.as_ref().map(|d| d.to_rfc3339()),
286            cost: work_report.cost,
287            invoice_number: work_report.invoice_number.clone(),
288            photos: work_report.photos.clone(),
289            documents: work_report.documents.clone(),
290            notes: work_report.notes.clone(),
291            warranty_type: work_report.warranty_type.clone(),
292            warranty_expiry: work_report.warranty_expiry.to_rfc3339(),
293            is_warranty_valid: work_report.is_warranty_valid(),
294            warranty_days_remaining: work_report.warranty_days_remaining(),
295            created_at: work_report.created_at.to_rfc3339(),
296            updated_at: work_report.updated_at.to_rfc3339(),
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::application::dto::{CreateWorkReportDto, UpdateWorkReportDto, WorkReportFilters};
305    use crate::application::ports::WorkReportRepository;
306    use crate::domain::entities::{WarrantyType, WorkReport, WorkType};
307    use async_trait::async_trait;
308    use chrono::Utc;
309    use rust_decimal_macros::dec;
310    use std::sync::Arc;
311    use tokio::sync::Mutex;
312    use uuid::Uuid;
313
314    /// In-memory mock for WorkReportRepository
315    struct MockWorkReportRepository {
316        reports: Mutex<Vec<WorkReport>>,
317    }
318
319    impl MockWorkReportRepository {
320        fn new() -> Self {
321            Self {
322                reports: Mutex::new(Vec::new()),
323            }
324        }
325
326        fn with_reports(reports: Vec<WorkReport>) -> Self {
327            Self {
328                reports: Mutex::new(reports),
329            }
330        }
331    }
332
333    #[async_trait]
334    impl WorkReportRepository for MockWorkReportRepository {
335        async fn create(&self, work_report: &WorkReport) -> Result<WorkReport, String> {
336            let mut reports = self.reports.lock().await;
337            reports.push(work_report.clone());
338            Ok(work_report.clone())
339        }
340
341        async fn find_by_id(&self, id: Uuid) -> Result<Option<WorkReport>, String> {
342            let reports = self.reports.lock().await;
343            Ok(reports.iter().find(|r| r.id == id).cloned())
344        }
345
346        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<WorkReport>, String> {
347            let reports = self.reports.lock().await;
348            Ok(reports
349                .iter()
350                .filter(|r| r.building_id == building_id)
351                .cloned()
352                .collect())
353        }
354
355        async fn find_by_organization(
356            &self,
357            organization_id: Uuid,
358        ) -> Result<Vec<WorkReport>, String> {
359            let reports = self.reports.lock().await;
360            Ok(reports
361                .iter()
362                .filter(|r| r.organization_id == organization_id)
363                .cloned()
364                .collect())
365        }
366
367        async fn find_all_paginated(
368            &self,
369            _page_request: &crate::application::dto::PageRequest,
370            _filters: &WorkReportFilters,
371        ) -> Result<(Vec<WorkReport>, i64), String> {
372            let reports = self.reports.lock().await;
373            let total = reports.len() as i64;
374            Ok((reports.clone(), total))
375        }
376
377        async fn find_with_active_warranty(
378            &self,
379            building_id: Uuid,
380        ) -> Result<Vec<WorkReport>, String> {
381            let reports = self.reports.lock().await;
382            Ok(reports
383                .iter()
384                .filter(|r| r.building_id == building_id && r.is_warranty_valid())
385                .cloned()
386                .collect())
387        }
388
389        async fn find_with_expiring_warranty(
390            &self,
391            building_id: Uuid,
392            days: i32,
393        ) -> Result<Vec<WorkReport>, String> {
394            let reports = self.reports.lock().await;
395            Ok(reports
396                .iter()
397                .filter(|r| {
398                    r.building_id == building_id
399                        && r.is_warranty_valid()
400                        && r.warranty_days_remaining() <= days as i64
401                })
402                .cloned()
403                .collect())
404        }
405
406        async fn update(&self, work_report: &WorkReport) -> Result<WorkReport, String> {
407            let mut reports = self.reports.lock().await;
408            if let Some(pos) = reports.iter().position(|r| r.id == work_report.id) {
409                reports[pos] = work_report.clone();
410            }
411            Ok(work_report.clone())
412        }
413
414        async fn delete(&self, id: Uuid) -> Result<bool, String> {
415            let mut reports = self.reports.lock().await;
416            let len_before = reports.len();
417            reports.retain(|r| r.id != id);
418            Ok(reports.len() < len_before)
419        }
420    }
421
422    fn make_org_and_building() -> (Uuid, Uuid) {
423        (Uuid::new_v4(), Uuid::new_v4())
424    }
425
426    #[tokio::test]
427    async fn test_create_work_report() {
428        let (org_id, building_id) = make_org_and_building();
429        let repo = Arc::new(MockWorkReportRepository::new());
430        let uc = WorkReportUseCases::new(repo);
431
432        let work_date = Utc::now().to_rfc3339();
433        let dto = CreateWorkReportDto {
434            organization_id: org_id.to_string(),
435            building_id: building_id.to_string(),
436            title: "Elevator repair".to_string(),
437            description: "Cable replacement".to_string(),
438            work_type: WorkType::Repair,
439            contractor_name: "Schindler".to_string(),
440            contractor_contact: None,
441            work_date,
442            completion_date: None,
443            cost: dec!(2500.00),
444            invoice_number: Some("INV-001".to_string()),
445            notes: None,
446            warranty_type: WarrantyType::Standard,
447        };
448
449        let result = uc.create_work_report(dto).await;
450        assert!(result.is_ok());
451        let resp = result.unwrap();
452        assert_eq!(resp.title, "Elevator repair");
453        assert_eq!(resp.cost, dec!(2500.00));
454        assert_eq!(resp.contractor_name, "Schindler");
455        assert!(resp.is_warranty_valid);
456    }
457
458    #[tokio::test]
459    async fn test_update_work_report() {
460        let (org_id, building_id) = make_org_and_building();
461        let report = WorkReport::new(
462            org_id,
463            building_id,
464            "Old title".to_string(),
465            "Old desc".to_string(),
466            WorkType::Maintenance,
467            "Contractor A".to_string(),
468            Utc::now(),
469            dec!(1000.00),
470            WarrantyType::None,
471        )
472        .expect("cout de test valide");
473        let report_id = report.id;
474
475        let repo = Arc::new(MockWorkReportRepository::with_reports(vec![report]));
476        let uc = WorkReportUseCases::new(repo);
477
478        let dto = UpdateWorkReportDto {
479            title: Some("Updated title".to_string()),
480            description: None,
481            work_type: None,
482            contractor_name: None,
483            contractor_contact: None,
484            work_date: None,
485            completion_date: None,
486            cost: Some(dec!(1500.00)),
487            invoice_number: None,
488            notes: None,
489            warranty_type: None,
490        };
491
492        let result = uc.update_work_report(report_id, dto).await;
493        assert!(result.is_ok());
494        let resp = result.unwrap();
495        assert_eq!(resp.title, "Updated title");
496        assert_eq!(resp.cost, dec!(1500.00));
497    }
498
499    #[tokio::test]
500    async fn test_list_work_reports_by_building() {
501        let (org_id, building_id) = make_org_and_building();
502        let other_building = Uuid::new_v4();
503
504        let r1 = WorkReport::new(
505            org_id,
506            building_id,
507            "Report 1".into(),
508            "Desc".into(),
509            WorkType::Repair,
510            "C1".into(),
511            Utc::now(),
512            dec!(100.00),
513            WarrantyType::None,
514        )
515        .expect("cout de test valide");
516        let r2 = WorkReport::new(
517            org_id,
518            building_id,
519            "Report 2".into(),
520            "Desc".into(),
521            WorkType::Maintenance,
522            "C2".into(),
523            Utc::now(),
524            dec!(200.00),
525            WarrantyType::None,
526        )
527        .expect("cout de test valide");
528        let r3 = WorkReport::new(
529            org_id,
530            other_building,
531            "Report 3".into(),
532            "Desc".into(),
533            WorkType::Emergency,
534            "C3".into(),
535            Utc::now(),
536            dec!(300.00),
537            WarrantyType::None,
538        )
539        .expect("cout de test valide");
540
541        let repo = Arc::new(MockWorkReportRepository::with_reports(vec![r1, r2, r3]));
542        let uc = WorkReportUseCases::new(repo);
543
544        let result = uc.list_work_reports_by_building(building_id).await;
545        assert!(result.is_ok());
546        let reports = result.unwrap();
547        assert_eq!(reports.len(), 2);
548        assert!(reports
549            .iter()
550            .all(|r| r.building_id == building_id.to_string()));
551    }
552
553    #[tokio::test]
554    async fn test_get_active_warranties() {
555        let (org_id, building_id) = make_org_and_building();
556
557        // Active warranty (Standard = 2 years from now)
558        let r1 = WorkReport::new(
559            org_id,
560            building_id,
561            "Active warranty".into(),
562            "Desc".into(),
563            WorkType::Renovation,
564            "C1".into(),
565            Utc::now(),
566            dec!(5000.00),
567            WarrantyType::Standard,
568        )
569        .expect("cout de test valide");
570        // No warranty
571        let r2 = WorkReport::new(
572            org_id,
573            building_id,
574            "No warranty".into(),
575            "Desc".into(),
576            WorkType::Maintenance,
577            "C2".into(),
578            Utc::now(),
579            dec!(100.00),
580            WarrantyType::None,
581        )
582        .expect("cout de test valide");
583
584        let repo = Arc::new(MockWorkReportRepository::with_reports(vec![r1, r2]));
585        let uc = WorkReportUseCases::new(repo);
586
587        let result = uc.get_active_warranties(building_id).await;
588        assert!(result.is_ok());
589        let warranties = result.unwrap();
590        assert_eq!(warranties.len(), 1);
591        assert_eq!(warranties[0].title, "Active warranty");
592        assert!(warranties[0].is_valid);
593        assert!(warranties[0].days_remaining > 700);
594    }
595
596    #[tokio::test]
597    async fn test_get_expiring_warranties() {
598        let (org_id, building_id) = make_org_and_building();
599
600        // Warranty expiring in ~30 days (custom 0-year warranty set to expire soon)
601        let mut r1 = WorkReport::new(
602            org_id,
603            building_id,
604            "Expiring soon".into(),
605            "Desc".into(),
606            WorkType::Repair,
607            "C1".into(),
608            Utc::now(),
609            dec!(1000.00),
610            WarrantyType::Standard,
611        )
612        .expect("cout de test valide");
613        // Override warranty_expiry to 20 days from now
614        r1.warranty_expiry = Utc::now() + chrono::Duration::days(20);
615
616        // Warranty valid for a long time (decennial)
617        let r2 = WorkReport::new(
618            org_id,
619            building_id,
620            "Long warranty".into(),
621            "Desc".into(),
622            WorkType::Renovation,
623            "C2".into(),
624            Utc::now(),
625            dec!(50000.00),
626            WarrantyType::Decennial,
627        )
628        .expect("cout de test valide");
629
630        let repo = Arc::new(MockWorkReportRepository::with_reports(vec![r1, r2]));
631        let uc = WorkReportUseCases::new(repo);
632
633        let result = uc.get_expiring_warranties(building_id, 30).await;
634        assert!(result.is_ok());
635        let expiring = result.unwrap();
636        assert_eq!(expiring.len(), 1);
637        assert_eq!(expiring[0].title, "Expiring soon");
638        assert!(expiring[0].days_remaining <= 30);
639    }
640}