Skip to main content

koprogo_api/application/use_cases/
quote_use_cases.rs

1use crate::application::dto::{
2    CreateQuoteDto, QuoteComparisonItemDto, QuoteComparisonRequestDto, QuoteComparisonResponseDto,
3    QuoteDecisionDto, QuoteResponseDto, QuoteScoreResponseDto, SubmitQuoteDto,
4};
5use crate::application::ports::QuoteRepository;
6use crate::domain::entities::{Quote, QuoteScore, QuoteSubmission};
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) — request phase, no pricing.
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 mut quote = Quote::new(
29            building_id,
30            contractor_id,
31            dto.project_title,
32            dto.project_description,
33            dto.work_category,
34            dto.warranty_years,
35        )?;
36
37        // Backward-compat escape hatch: price provided directly at request
38        // time (cf. CreateQuoteDto docs) — stays Requested, still needs a
39        // separate submit() to become Received.
40        if let (Some(amount_excl_vat), Some(vat_rate), Some(validity_date_str)) =
41            (dto.amount_excl_vat, dto.vat_rate, dto.validity_date)
42        {
43            let validity_date = DateTime::parse_from_rfc3339(&validity_date_str)
44                .map_err(|_| "Invalid validity_date format".to_string())?
45                .with_timezone(&Utc);
46            quote.set_initial_pricing(QuoteSubmission {
47                amount_excl_vat,
48                vat_rate,
49                validity_date,
50                estimated_duration_days: dto.estimated_duration_days.unwrap_or(0),
51                warranty_years: dto.warranty_years,
52            })?;
53        }
54
55        let created = self.repository.create(&quote).await?;
56        Ok(QuoteResponseDto::from(created))
57    }
58
59    /// Submit quote (Contractor/Syndic action). `pricing` is optional: a
60    /// quote already carrying price data (cf. `create_quote`'s escape
61    /// hatch) can be submitted bodyless, otherwise pricing is required.
62    pub async fn submit_quote(
63        &self,
64        quote_id: Uuid,
65        pricing: Option<SubmitQuoteDto>,
66    ) -> Result<QuoteResponseDto, String> {
67        let mut quote = self
68            .repository
69            .find_by_id(quote_id)
70            .await?
71            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
72
73        let pricing = pricing.map(|dto| dto.into_domain()).transpose()?;
74        quote.submit(pricing)?;
75
76        let updated = self.repository.update(&quote).await?;
77        Ok(QuoteResponseDto::from(updated))
78    }
79
80    /// Start quote review (Syndic action)
81    pub async fn start_review(&self, quote_id: Uuid) -> Result<QuoteResponseDto, String> {
82        let mut quote = self
83            .repository
84            .find_by_id(quote_id)
85            .await?
86            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
87
88        quote.start_review()?;
89
90        let updated = self.repository.update(&quote).await?;
91        Ok(QuoteResponseDto::from(updated))
92    }
93
94    /// Accept quote (Syndic action - winner)
95    pub async fn accept_quote(
96        &self,
97        quote_id: Uuid,
98        decision_by: Uuid,
99        dto: QuoteDecisionDto,
100    ) -> Result<QuoteResponseDto, String> {
101        let mut quote = self
102            .repository
103            .find_by_id(quote_id)
104            .await?
105            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
106
107        quote.accept(decision_by, dto.decision_notes)?;
108
109        let updated = self.repository.update(&quote).await?;
110        Ok(QuoteResponseDto::from(updated))
111    }
112
113    /// Reject quote (Syndic action - loser or unqualified)
114    pub async fn reject_quote(
115        &self,
116        quote_id: Uuid,
117        decision_by: Uuid,
118        dto: QuoteDecisionDto,
119    ) -> Result<QuoteResponseDto, String> {
120        let mut quote = self
121            .repository
122            .find_by_id(quote_id)
123            .await?
124            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
125
126        quote.reject(decision_by, dto.decision_notes)?;
127
128        let updated = self.repository.update(&quote).await?;
129        Ok(QuoteResponseDto::from(updated))
130    }
131
132    /// Withdraw quote (Contractor action)
133    pub async fn withdraw_quote(&self, quote_id: Uuid) -> Result<QuoteResponseDto, String> {
134        let mut quote = self
135            .repository
136            .find_by_id(quote_id)
137            .await?
138            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
139
140        quote.withdraw()?;
141
142        let updated = self.repository.update(&quote).await?;
143        Ok(QuoteResponseDto::from(updated))
144    }
145
146    /// Compare multiple quotes (Belgian professional best practice: 3 quotes minimum for works >5000€)
147    /// Returns quotes sorted by total score (best first)
148    pub async fn compare_quotes(
149        &self,
150        dto: QuoteComparisonRequestDto,
151    ) -> Result<QuoteComparisonResponseDto, String> {
152        if dto.quote_ids.len() < 3 {
153            return Err("Best practice requires at least 3 quotes for comparison".to_string());
154        }
155
156        // Parse quote IDs
157        let quote_ids: Result<Vec<Uuid>, _> = dto
158            .quote_ids
159            .iter()
160            .map(|id_str| {
161                Uuid::parse_str(id_str).map_err(|_| format!("Invalid quote_id format: {}", id_str))
162            })
163            .collect();
164        let quote_ids = quote_ids?;
165
166        // Fetch all quotes
167        let quotes = self.repository.find_by_ids(quote_ids).await?;
168
169        if quotes.len() < 3 {
170            return Err(format!(
171                "Found only {} quotes, Belgian law requires at least 3",
172                quotes.len()
173            ));
174        }
175
176        // Ensure all quotes are for the same project. Quotes not yet
177        // submitted (no price data — status "Requested") are a normal,
178        // reachable state now that quote creation doesn't require pricing
179        // up front: they're included in the response so the syndic can see
180        // they're still pending, but excluded from scoring/aggregates since
181        // there's nothing to score yet.
182        let building_id = quotes[0].building_id;
183        let project_title = quotes[0].project_title.clone();
184        for quote in &quotes {
185            if quote.building_id != building_id {
186                return Err("All quotes must be for the same building".to_string());
187            }
188            if quote.project_title != project_title {
189                return Err("All quotes must be for the same project".to_string());
190            }
191        }
192
193        let (priced_quotes, pending_quotes): (Vec<Quote>, Vec<Quote>) = quotes
194            .into_iter()
195            .partition(|q| q.amount_incl_vat.is_some());
196
197        // Calculate aggregated statistics (priced quotes only)
198        let min_price = priced_quotes
199            .iter()
200            .filter_map(|q| q.amount_incl_vat)
201            .min()
202            .unwrap_or(Decimal::ZERO);
203        let max_price = priced_quotes
204            .iter()
205            .filter_map(|q| q.amount_incl_vat)
206            .max()
207            .unwrap_or(Decimal::ZERO);
208        let avg_price = if priced_quotes.is_empty() {
209            Decimal::ZERO
210        } else {
211            priced_quotes
212                .iter()
213                .filter_map(|q| q.amount_incl_vat)
214                .sum::<Decimal>()
215                / Decimal::from(priced_quotes.len())
216        };
217
218        let min_duration_days = priced_quotes
219            .iter()
220            .filter_map(|q| q.estimated_duration_days)
221            .min()
222            .unwrap_or(0);
223        let max_duration_days = priced_quotes
224            .iter()
225            .filter_map(|q| q.estimated_duration_days)
226            .max()
227            .unwrap_or(0);
228        let avg_duration_days = if priced_quotes.is_empty() {
229            0.0
230        } else {
231            priced_quotes
232                .iter()
233                .filter_map(|q| q.estimated_duration_days)
234                .sum::<i32>() as f32
235                / priced_quotes.len() as f32
236        };
237
238        let max_warranty = priced_quotes
239            .iter()
240            .map(|q| q.warranty_years)
241            .max()
242            .unwrap_or(0);
243
244        // Calculate scores for priced quotes only
245        let mut scored_quotes: Vec<(Quote, QuoteScore)> = Vec::new();
246        for quote in priced_quotes {
247            let score = quote.calculate_score(
248                min_price,
249                max_price,
250                min_duration_days,
251                max_duration_days,
252                max_warranty,
253            )?;
254            scored_quotes.push((quote, score));
255        }
256
257        // Sort by total score (descending - best first)
258        scored_quotes.sort_by(|a, b| {
259            b.1.total_score
260                .partial_cmp(&a.1.total_score)
261                .unwrap_or(std::cmp::Ordering::Equal)
262        });
263
264        // Build comparison items: scored (priced) quotes first, ranked by
265        // score, then pending (unpriced) quotes appended with no score.
266        let mut comparison_items: Vec<QuoteComparisonItemDto> = scored_quotes
267            .into_iter()
268            .enumerate()
269            .map(|(index, (quote, score))| QuoteComparisonItemDto {
270                quote: QuoteResponseDto::from(quote),
271                score: Some(QuoteScoreResponseDto::from(score)),
272                rank: index + 1, // 1-indexed ranking
273            })
274            .collect();
275        let ranked_count = comparison_items.len();
276        comparison_items.extend(
277            pending_quotes
278                .into_iter()
279                .enumerate()
280                .map(|(index, quote)| QuoteComparisonItemDto {
281                    quote: QuoteResponseDto::from(quote),
282                    score: None,
283                    rank: ranked_count + index + 1,
284                }),
285        );
286
287        // Recommend top-ranked (scored) quote
288        let recommended_quote_id = comparison_items
289            .iter()
290            .find(|item| item.score.is_some())
291            .map(|item| item.quote.id.clone());
292
293        Ok(QuoteComparisonResponseDto {
294            project_title,
295            building_id: building_id.to_string(),
296            total_quotes: comparison_items.len(),
297            comparison_items,
298            min_price: min_price.to_string(),
299            max_price: max_price.to_string(),
300            avg_price: avg_price.to_string(),
301            min_duration_days,
302            max_duration_days,
303            avg_duration_days,
304            recommended_quote_id,
305        })
306    }
307
308    /// Get quote by ID
309    pub async fn get_quote(&self, quote_id: Uuid) -> Result<Option<QuoteResponseDto>, String> {
310        let quote = self.repository.find_by_id(quote_id).await?;
311        Ok(quote.map(QuoteResponseDto::from))
312    }
313
314    /// List quotes by building
315    pub async fn list_by_building(
316        &self,
317        building_id: Uuid,
318    ) -> Result<Vec<QuoteResponseDto>, String> {
319        let quotes = self.repository.find_by_building(building_id).await?;
320        Ok(quotes.into_iter().map(QuoteResponseDto::from).collect())
321    }
322
323    /// List quotes by contractor
324    pub async fn list_by_contractor(
325        &self,
326        contractor_id: Uuid,
327    ) -> Result<Vec<QuoteResponseDto>, String> {
328        let quotes = self.repository.find_by_contractor(contractor_id).await?;
329        Ok(quotes.into_iter().map(QuoteResponseDto::from).collect())
330    }
331
332    /// List quotes by status
333    pub async fn list_by_status(
334        &self,
335        building_id: Uuid,
336        status: &str,
337    ) -> Result<Vec<QuoteResponseDto>, String> {
338        let quotes = self.repository.find_by_status(building_id, status).await?;
339        Ok(quotes.into_iter().map(QuoteResponseDto::from).collect())
340    }
341
342    /// List quotes by project title
343    pub async fn list_by_project_title(
344        &self,
345        building_id: Uuid,
346        project_title: &str,
347    ) -> Result<Vec<QuoteResponseDto>, String> {
348        let quotes = self
349            .repository
350            .find_by_project_title(building_id, project_title)
351            .await?;
352        Ok(quotes.into_iter().map(QuoteResponseDto::from).collect())
353    }
354
355    /// Update contractor rating (for scoring)
356    pub async fn update_contractor_rating(
357        &self,
358        quote_id: Uuid,
359        rating: i32,
360    ) -> Result<QuoteResponseDto, String> {
361        let mut quote = self
362            .repository
363            .find_by_id(quote_id)
364            .await?
365            .ok_or_else(|| format!("Quote not found: {}", quote_id))?;
366
367        quote.set_contractor_rating(rating)?;
368
369        let updated = self.repository.update(&quote).await?;
370        Ok(QuoteResponseDto::from(updated))
371    }
372
373    /// Mark expired quotes (background job)
374    /// Returns count of quotes marked as expired
375    pub async fn mark_expired_quotes(&self) -> Result<usize, String> {
376        let expired_quotes = self.repository.find_expired().await?;
377
378        let mut count = 0;
379        for mut quote in expired_quotes {
380            if quote.mark_expired().is_ok() {
381                self.repository.update(&quote).await?;
382                count += 1;
383            }
384        }
385
386        Ok(count)
387    }
388
389    /// Delete quote
390    pub async fn delete_quote(&self, quote_id: Uuid) -> Result<bool, String> {
391        self.repository.delete(quote_id).await
392    }
393
394    /// Count quotes by building
395    pub async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
396        self.repository.count_by_building(building_id).await
397    }
398
399    /// Count quotes by status
400    pub async fn count_by_status(&self, building_id: Uuid, status: &str) -> Result<i64, String> {
401        self.repository.count_by_status(building_id, status).await
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::application::ports::QuoteRepository;
409    use crate::domain::entities::Quote;
410    use async_trait::async_trait;
411    use mockall::mock;
412    use rust_decimal::Decimal;
413    use std::str::FromStr;
414
415    // Helper macro since dec! is not available in rust_decimal 1.36
416    macro_rules! dec {
417        ($val:expr) => {
418            Decimal::from_str(stringify!($val)).unwrap()
419        };
420    }
421
422    mock! {
423        QuoteRepo {}
424
425        #[async_trait]
426        impl QuoteRepository for QuoteRepo {
427            async fn create(&self, quote: &Quote) -> Result<Quote, String>;
428            async fn find_by_id(&self, id: Uuid) -> Result<Option<Quote>, String>;
429            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Quote>, String>;
430            async fn find_by_contractor(&self, contractor_id: Uuid) -> Result<Vec<Quote>, String>;
431            async fn find_by_status(&self, building_id: Uuid, status: &str) -> Result<Vec<Quote>, String>;
432            async fn find_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<Quote>, String>;
433            async fn find_by_project_title(&self, building_id: Uuid, project_title: &str) -> Result<Vec<Quote>, String>;
434            async fn find_expired(&self) -> Result<Vec<Quote>, String>;
435            async fn update(&self, quote: &Quote) -> Result<Quote, String>;
436            async fn delete(&self, id: Uuid) -> Result<bool, String>;
437            async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String>;
438            async fn count_by_status(&self, building_id: Uuid, status: &str) -> Result<i64, String>;
439        }
440    }
441
442    #[tokio::test]
443    async fn test_create_quote_success() {
444        let mut mock_repo = MockQuoteRepo::new();
445
446        mock_repo
447            .expect_create()
448            .returning(|quote| Ok(quote.clone()));
449
450        let use_cases = QuoteUseCases::new(Arc::new(mock_repo));
451
452        let dto = CreateQuoteDto {
453            building_id: Uuid::new_v4().to_string(),
454            contractor_id: Uuid::new_v4().to_string(),
455            project_title: "Roof Repair".to_string(),
456            project_description: "Fix leaking roof".to_string(),
457            work_category: Some("roofing".to_string()),
458            amount_excl_vat: None,
459            vat_rate: None,
460            validity_date: None,
461            estimated_start_date: None,
462            estimated_duration_days: None,
463            warranty_years: 10,
464        };
465
466        let result = use_cases.create_quote(dto).await;
467        assert!(result.is_ok());
468        assert_eq!(result.unwrap().status, "Requested");
469    }
470
471    #[tokio::test]
472    async fn test_submit_quote() {
473        let mut mock_repo = MockQuoteRepo::new();
474        let quote_id = Uuid::new_v4();
475        let building_id = Uuid::new_v4();
476        let contractor_id = Uuid::new_v4();
477
478        let quote = Quote::new(
479            building_id,
480            contractor_id,
481            "Test".to_string(),
482            "Desc".to_string(),
483            Some("roofing".to_string()),
484            10,
485        )
486        .unwrap();
487
488        mock_repo
489            .expect_find_by_id()
490            .returning(move |_| Ok(Some(quote.clone())));
491        mock_repo
492            .expect_update()
493            .returning(|quote| Ok(quote.clone()));
494
495        let use_cases = QuoteUseCases::new(Arc::new(mock_repo));
496
497        let pricing = SubmitQuoteDto {
498            amount_excl_vat_cents: 500_000,
499            vat_rate: dec!(21.00),
500            validity_date: (Utc::now() + chrono::Duration::days(30)).to_rfc3339(),
501            estimated_duration_days: 14,
502            warranty_years: 10,
503        };
504        let result = use_cases.submit_quote(quote_id, Some(pricing)).await;
505        assert!(result.is_ok());
506        assert_eq!(result.unwrap().status, "Received");
507    }
508
509    #[tokio::test]
510    async fn test_compare_quotes_requires_minimum_3() {
511        let mock_repo = MockQuoteRepo::new();
512        let use_cases = QuoteUseCases::new(Arc::new(mock_repo));
513
514        let dto = QuoteComparisonRequestDto {
515            quote_ids: vec![Uuid::new_v4().to_string(), Uuid::new_v4().to_string()],
516        };
517
518        let result = use_cases.compare_quotes(dto).await;
519        assert!(result.is_err());
520        assert_eq!(
521            result.unwrap_err(),
522            "Best practice requires at least 3 quotes for comparison"
523        );
524    }
525
526    #[tokio::test]
527    async fn test_compare_quotes_with_pending_unpriced_quote() {
528        use crate::domain::entities::QuoteSubmission;
529
530        let building_id = Uuid::new_v4();
531        let contractor_id = Uuid::new_v4();
532
533        let mut priced_a = Quote::new(
534            building_id,
535            contractor_id,
536            "Roof Repair".to_string(),
537            "Fix leaking roof".to_string(),
538            Some("roofing".to_string()),
539            2,
540        )
541        .unwrap();
542        priced_a
543            .submit(Some(QuoteSubmission {
544                amount_excl_vat: dec!(4000.00),
545                vat_rate: dec!(0.21),
546                validity_date: Utc::now() + chrono::Duration::days(30),
547                estimated_duration_days: 10,
548                warranty_years: 2,
549            }))
550            .unwrap();
551
552        let mut priced_b = Quote::new(
553            building_id,
554            contractor_id,
555            "Roof Repair".to_string(),
556            "Fix leaking roof".to_string(),
557            Some("roofing".to_string()),
558            2,
559        )
560        .unwrap();
561        priced_b
562            .submit(Some(QuoteSubmission {
563                amount_excl_vat: dec!(5000.00),
564                vat_rate: dec!(0.21),
565                validity_date: Utc::now() + chrono::Duration::days(30),
566                estimated_duration_days: 12,
567                warranty_years: 2,
568            }))
569            .unwrap();
570
571        // Third quote still awaiting a price — a normal, reachable state
572        // since "Demander un devis" no longer requires pricing up front.
573        let pending = Quote::new(
574            building_id,
575            contractor_id,
576            "Roof Repair".to_string(),
577            "Fix leaking roof".to_string(),
578            Some("roofing".to_string()),
579            2,
580        )
581        .unwrap();
582
583        let quotes = vec![priced_a.clone(), priced_b.clone(), pending.clone()];
584        let ids: Vec<String> = quotes.iter().map(|q| q.id.to_string()).collect();
585
586        let mut mock_repo = MockQuoteRepo::new();
587        mock_repo
588            .expect_find_by_ids()
589            .returning(move |_| Ok(quotes.clone()));
590
591        let use_cases = QuoteUseCases::new(Arc::new(mock_repo));
592        let dto = QuoteComparisonRequestDto { quote_ids: ids };
593
594        let result = use_cases.compare_quotes(dto).await.unwrap();
595
596        assert_eq!(result.total_quotes, 3);
597        // Aggregates computed only over the 2 priced quotes. Decimal::eq
598        // ignores scale, but the DTO stores these as strings, so parse back.
599        assert_eq!(Decimal::from_str(&result.min_price).unwrap(), dec!(4840.00));
600        assert_eq!(Decimal::from_str(&result.max_price).unwrap(), dec!(6050.00));
601        // The pending quote must still appear in the comparison, unscored.
602        let pending_item = result
603            .comparison_items
604            .iter()
605            .find(|item| item.quote.id == pending.id.to_string())
606            .expect("pending quote missing from comparison");
607        assert!(pending_item.score.is_none());
608        // Recommendation must point at a scored quote, never the pending one.
609        let recommended_id = result.recommended_quote_id.unwrap();
610        assert_ne!(recommended_id, pending.id.to_string());
611    }
612}