koprogo_api/application/use_cases/
quote_use_cases.rs

1use crate::application::dto::{
2    CreateQuoteDto, QuoteComparisonItemDto, QuoteComparisonRequestDto, QuoteComparisonResponseDto,
3    QuoteDecisionDto, QuoteResponseDto, QuoteScoreResponseDto,
4};
5use crate::application::ports::QuoteRepository;
6use crate::domain::entities::{Quote, QuoteScore};
7use chrono::{DateTime, Utc};
8use rust_decimal::Decimal;
9use std::sync::Arc;
10use uuid::Uuid;
11
12pub struct QuoteUseCases {
13    repository: Arc<dyn QuoteRepository>,
14}
15
16impl QuoteUseCases {
17    pub fn new(repository: Arc<dyn QuoteRepository>) -> Self {
18        Self { repository }
19    }
20
21    /// Create new quote request (Syndic action)
22    pub async fn create_quote(&self, dto: CreateQuoteDto) -> Result<QuoteResponseDto, String> {
23        let building_id = Uuid::parse_str(&dto.building_id)
24            .map_err(|_| "Invalid building_id format".to_string())?;
25        let contractor_id = Uuid::parse_str(&dto.contractor_id)
26            .map_err(|_| "Invalid contractor_id format".to_string())?;
27
28        let validity_date = DateTime::parse_from_rfc3339(&dto.validity_date)
29            .map_err(|_| "Invalid validity_date format".to_string())?
30            .with_timezone(&Utc);
31
32        let _estimated_start_date = if let Some(date_str) = &dto.estimated_start_date {
33            Some(
34                DateTime::parse_from_rfc3339(date_str)
35                    .map_err(|_| "Invalid estimated_start_date format".to_string())?
36                    .with_timezone(&Utc),
37            )
38        } else {
39            None
40        };
41
42        let quote = Quote::new(
43            building_id,
44            contractor_id,
45            dto.project_title,
46            dto.project_description,
47            dto.amount_excl_vat,
48            dto.vat_rate,
49            validity_date,
50            dto.estimated_duration_days,
51            dto.warranty_years,
52        )?;
53
54        let created = self.repository.create(&quote).await?;
55        Ok(QuoteResponseDto::from(created))
56    }
57
58    /// Submit quote (Contractor action)
59    pub async fn submit_quote(&self, quote_id: Uuid) -> Result<QuoteResponseDto, String> {
60        let mut quote = self
61            .repository
62            .find_by_id(quote_id)
63            .await?
64            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
65
66        quote.submit()?;
67
68        let updated = self.repository.update(&quote).await?;
69        Ok(QuoteResponseDto::from(updated))
70    }
71
72    /// Start quote review (Syndic action)
73    pub async fn start_review(&self, quote_id: Uuid) -> Result<QuoteResponseDto, String> {
74        let mut quote = self
75            .repository
76            .find_by_id(quote_id)
77            .await?
78            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
79
80        quote.start_review()?;
81
82        let updated = self.repository.update(&quote).await?;
83        Ok(QuoteResponseDto::from(updated))
84    }
85
86    /// Accept quote (Syndic action - winner)
87    pub async fn accept_quote(
88        &self,
89        quote_id: Uuid,
90        decision_by: Uuid,
91        dto: QuoteDecisionDto,
92    ) -> Result<QuoteResponseDto, String> {
93        let mut quote = self
94            .repository
95            .find_by_id(quote_id)
96            .await?
97            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
98
99        quote.accept(decision_by, dto.decision_notes)?;
100
101        let updated = self.repository.update(&quote).await?;
102        Ok(QuoteResponseDto::from(updated))
103    }
104
105    /// Reject quote (Syndic action - loser or unqualified)
106    pub async fn reject_quote(
107        &self,
108        quote_id: Uuid,
109        decision_by: Uuid,
110        dto: QuoteDecisionDto,
111    ) -> Result<QuoteResponseDto, String> {
112        let mut quote = self
113            .repository
114            .find_by_id(quote_id)
115            .await?
116            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
117
118        quote.reject(decision_by, dto.decision_notes)?;
119
120        let updated = self.repository.update(&quote).await?;
121        Ok(QuoteResponseDto::from(updated))
122    }
123
124    /// Withdraw quote (Contractor action)
125    pub async fn withdraw_quote(&self, quote_id: Uuid) -> Result<QuoteResponseDto, String> {
126        let mut quote = self
127            .repository
128            .find_by_id(quote_id)
129            .await?
130            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
131
132        quote.withdraw()?;
133
134        let updated = self.repository.update(&quote).await?;
135        Ok(QuoteResponseDto::from(updated))
136    }
137
138    /// Compare multiple quotes (Belgian legal requirement: 3 quotes minimum for works >5000€)
139    /// Returns quotes sorted by total score (best first)
140    pub async fn compare_quotes(
141        &self,
142        dto: QuoteComparisonRequestDto,
143    ) -> Result<QuoteComparisonResponseDto, String> {
144        if dto.quote_ids.len() < 3 {
145            return Err("Belgian law requires at least 3 quotes for comparison".to_string());
146        }
147
148        // Parse quote IDs
149        let quote_ids: Result<Vec<Uuid>, _> = dto
150            .quote_ids
151            .iter()
152            .map(|id_str| {
153                Uuid::parse_str(id_str).map_err(|_| format!("Invalid quote_id format: {}", id_str))
154            })
155            .collect();
156        let quote_ids = quote_ids?;
157
158        // Fetch all quotes
159        let quotes = self.repository.find_by_ids(quote_ids).await?;
160
161        if quotes.len() < 3 {
162            return Err(format!(
163                "Found only {} quotes, Belgian law requires at least 3",
164                quotes.len()
165            ));
166        }
167
168        // Ensure all quotes are for the same project
169        let building_id = quotes[0].building_id;
170        let project_title = quotes[0].project_title.clone();
171        for quote in &quotes {
172            if quote.building_id != building_id {
173                return Err("All quotes must be for the same building".to_string());
174            }
175            if quote.project_title != project_title {
176                return Err("All quotes must be for the same project".to_string());
177            }
178        }
179
180        // Calculate aggregated statistics
181        let min_price = quotes
182            .iter()
183            .map(|q| q.amount_incl_vat)
184            .min()
185            .unwrap_or(Decimal::ZERO);
186        let max_price = quotes
187            .iter()
188            .map(|q| q.amount_incl_vat)
189            .max()
190            .unwrap_or(Decimal::ZERO);
191        let avg_price =
192            quotes.iter().map(|q| q.amount_incl_vat).sum::<Decimal>() / Decimal::from(quotes.len());
193
194        let min_duration_days = quotes
195            .iter()
196            .map(|q| q.estimated_duration_days)
197            .min()
198            .unwrap_or(0);
199        let max_duration_days = quotes
200            .iter()
201            .map(|q| q.estimated_duration_days)
202            .max()
203            .unwrap_or(0);
204        let avg_duration_days = quotes
205            .iter()
206            .map(|q| q.estimated_duration_days)
207            .sum::<i32>() as f32
208            / quotes.len() as f32;
209
210        let max_warranty = quotes.iter().map(|q| q.warranty_years).max().unwrap_or(0);
211
212        // Calculate scores for each quote
213        let mut scored_quotes: Vec<(Quote, QuoteScore)> = Vec::new();
214        for quote in quotes {
215            let score = quote.calculate_score(
216                min_price,
217                max_price,
218                min_duration_days,
219                max_duration_days,
220                max_warranty,
221            )?;
222            scored_quotes.push((quote, score));
223        }
224
225        // Sort by total score (descending - best first)
226        scored_quotes.sort_by(|a, b| {
227            b.1.total_score
228                .partial_cmp(&a.1.total_score)
229                .unwrap_or(std::cmp::Ordering::Equal)
230        });
231
232        // Build comparison items with ranking
233        let comparison_items: Vec<QuoteComparisonItemDto> = scored_quotes
234            .into_iter()
235            .enumerate()
236            .map(|(index, (quote, score))| QuoteComparisonItemDto {
237                quote: QuoteResponseDto::from(quote),
238                score: Some(QuoteScoreResponseDto::from(score)),
239                rank: index + 1, // 1-indexed ranking
240            })
241            .collect();
242
243        // Recommend top-ranked quote
244        let recommended_quote_id = comparison_items.first().map(|item| item.quote.id.clone());
245
246        Ok(QuoteComparisonResponseDto {
247            project_title,
248            building_id: building_id.to_string(),
249            total_quotes: comparison_items.len(),
250            comparison_items,
251            min_price: min_price.to_string(),
252            max_price: max_price.to_string(),
253            avg_price: avg_price.to_string(),
254            min_duration_days,
255            max_duration_days,
256            avg_duration_days,
257            recommended_quote_id,
258        })
259    }
260
261    /// Get quote by ID
262    pub async fn get_quote(&self, quote_id: Uuid) -> Result<Option<QuoteResponseDto>, String> {
263        let quote = self.repository.find_by_id(quote_id).await?;
264        Ok(quote.map(QuoteResponseDto::from))
265    }
266
267    /// List quotes by building
268    pub async fn list_by_building(
269        &self,
270        building_id: Uuid,
271    ) -> Result<Vec<QuoteResponseDto>, String> {
272        let quotes = self.repository.find_by_building(building_id).await?;
273        Ok(quotes.into_iter().map(QuoteResponseDto::from).collect())
274    }
275
276    /// List quotes by contractor
277    pub async fn list_by_contractor(
278        &self,
279        contractor_id: Uuid,
280    ) -> Result<Vec<QuoteResponseDto>, String> {
281        let quotes = self.repository.find_by_contractor(contractor_id).await?;
282        Ok(quotes.into_iter().map(QuoteResponseDto::from).collect())
283    }
284
285    /// List quotes by status
286    pub async fn list_by_status(
287        &self,
288        building_id: Uuid,
289        status: &str,
290    ) -> Result<Vec<QuoteResponseDto>, String> {
291        let quotes = self.repository.find_by_status(building_id, status).await?;
292        Ok(quotes.into_iter().map(QuoteResponseDto::from).collect())
293    }
294
295    /// List quotes by project title
296    pub async fn list_by_project_title(
297        &self,
298        building_id: Uuid,
299        project_title: &str,
300    ) -> Result<Vec<QuoteResponseDto>, String> {
301        let quotes = self
302            .repository
303            .find_by_project_title(building_id, project_title)
304            .await?;
305        Ok(quotes.into_iter().map(QuoteResponseDto::from).collect())
306    }
307
308    /// Update contractor rating (for scoring)
309    pub async fn update_contractor_rating(
310        &self,
311        quote_id: Uuid,
312        rating: i32,
313    ) -> Result<QuoteResponseDto, String> {
314        let mut quote = self
315            .repository
316            .find_by_id(quote_id)
317            .await?
318            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
319
320        quote.set_contractor_rating(rating)?;
321
322        let updated = self.repository.update(&quote).await?;
323        Ok(QuoteResponseDto::from(updated))
324    }
325
326    /// Mark expired quotes (background job)
327    /// Returns count of quotes marked as expired
328    pub async fn mark_expired_quotes(&self) -> Result<usize, String> {
329        let expired_quotes = self.repository.find_expired().await?;
330
331        let mut count = 0;
332        for mut quote in expired_quotes {
333            if quote.mark_expired().is_ok() {
334                self.repository.update(&quote).await?;
335                count += 1;
336            }
337        }
338
339        Ok(count)
340    }
341
342    /// Delete quote
343    pub async fn delete_quote(&self, quote_id: Uuid) -> Result<bool, String> {
344        self.repository.delete(quote_id).await
345    }
346
347    /// Count quotes by building
348    pub async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
349        self.repository.count_by_building(building_id).await
350    }
351
352    /// Count quotes by status
353    pub async fn count_by_status(&self, building_id: Uuid, status: &str) -> Result<i64, String> {
354        self.repository.count_by_status(building_id, status).await
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::application::ports::QuoteRepository;
362    use crate::domain::entities::Quote;
363    use async_trait::async_trait;
364    use mockall::mock;
365    use rust_decimal::Decimal;
366    use std::str::FromStr;
367
368    // Helper macro since dec! is not available in rust_decimal 1.36
369    macro_rules! dec {
370        ($val:expr) => {
371            Decimal::from_str(stringify!($val)).unwrap()
372        };
373    }
374
375    mock! {
376        QuoteRepo {}
377
378        #[async_trait]
379        impl QuoteRepository for QuoteRepo {
380            async fn create(&self, quote: &Quote) -> Result<Quote, String>;
381            async fn find_by_id(&self, id: Uuid) -> Result<Option<Quote>, String>;
382            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Quote>, String>;
383            async fn find_by_contractor(&self, contractor_id: Uuid) -> Result<Vec<Quote>, String>;
384            async fn find_by_status(&self, building_id: Uuid, status: &str) -> Result<Vec<Quote>, String>;
385            async fn find_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<Quote>, String>;
386            async fn find_by_project_title(&self, building_id: Uuid, project_title: &str) -> Result<Vec<Quote>, String>;
387            async fn find_expired(&self) -> Result<Vec<Quote>, String>;
388            async fn update(&self, quote: &Quote) -> Result<Quote, String>;
389            async fn delete(&self, id: Uuid) -> Result<bool, String>;
390            async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String>;
391            async fn count_by_status(&self, building_id: Uuid, status: &str) -> Result<i64, String>;
392        }
393    }
394
395    #[tokio::test]
396    async fn test_create_quote_success() {
397        let mut mock_repo = MockQuoteRepo::new();
398
399        mock_repo
400            .expect_create()
401            .returning(|quote| Ok(quote.clone()));
402
403        let use_cases = QuoteUseCases::new(Arc::new(mock_repo));
404
405        let dto = CreateQuoteDto {
406            building_id: Uuid::new_v4().to_string(),
407            contractor_id: Uuid::new_v4().to_string(),
408            project_title: "Roof Repair".to_string(),
409            project_description: "Fix leaking roof".to_string(),
410            amount_excl_vat: dec!(5000.00),
411            vat_rate: dec!(0.21),
412            validity_date: (Utc::now() + chrono::Duration::days(30)).to_rfc3339(),
413            estimated_start_date: None,
414            estimated_duration_days: 14,
415            warranty_years: 10,
416        };
417
418        let result = use_cases.create_quote(dto).await;
419        assert!(result.is_ok());
420    }
421
422    #[tokio::test]
423    async fn test_submit_quote() {
424        let mut mock_repo = MockQuoteRepo::new();
425        let quote_id = Uuid::new_v4();
426        let building_id = Uuid::new_v4();
427        let contractor_id = Uuid::new_v4();
428
429        let quote = Quote::new(
430            building_id,
431            contractor_id,
432            "Test".to_string(),
433            "Desc".to_string(),
434            dec!(5000.00),
435            dec!(0.21),
436            Utc::now() + chrono::Duration::days(30),
437            14,
438            10,
439        )
440        .unwrap();
441
442        mock_repo
443            .expect_find_by_id()
444            .returning(move |_| Ok(Some(quote.clone())));
445        mock_repo
446            .expect_update()
447            .returning(|quote| Ok(quote.clone()));
448
449        let use_cases = QuoteUseCases::new(Arc::new(mock_repo));
450
451        let result = use_cases.submit_quote(quote_id).await;
452        assert!(result.is_ok());
453        assert_eq!(result.unwrap().status, "Received");
454    }
455
456    #[tokio::test]
457    async fn test_compare_quotes_requires_minimum_3() {
458        let mock_repo = MockQuoteRepo::new();
459        let use_cases = QuoteUseCases::new(Arc::new(mock_repo));
460
461        let dto = QuoteComparisonRequestDto {
462            quote_ids: vec![Uuid::new_v4().to_string(), Uuid::new_v4().to_string()],
463        };
464
465        let result = use_cases.compare_quotes(dto).await;
466        assert!(result.is_err());
467        assert_eq!(
468            result.unwrap_err(),
469            "Belgian law requires at least 3 quotes for comparison"
470        );
471    }
472}