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 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 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("e).await?;
56 Ok(QuoteResponseDto::from(created))
57 }
58
59 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("e).await?;
77 Ok(QuoteResponseDto::from(updated))
78 }
79
80 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("e).await?;
91 Ok(QuoteResponseDto::from(updated))
92 }
93
94 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("e).await?;
110 Ok(QuoteResponseDto::from(updated))
111 }
112
113 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("e).await?;
129 Ok(QuoteResponseDto::from(updated))
130 }
131
132 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("e).await?;
143 Ok(QuoteResponseDto::from(updated))
144 }
145
146 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 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 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 let building_id = quotes[0].building_id;
183 let project_title = quotes[0].project_title.clone();
184 for quote in "es {
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 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 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 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 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, })
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 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 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 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 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 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 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 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("e).await?;
370 Ok(QuoteResponseDto::from(updated))
371 }
372
373 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("e).await?;
382 count += 1;
383 }
384 }
385
386 Ok(count)
387 }
388
389 pub async fn delete_quote(&self, quote_id: Uuid) -> Result<bool, String> {
391 self.repository.delete(quote_id).await
392 }
393
394 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 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 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 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 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 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 let recommended_id = result.recommended_quote_id.unwrap();
610 assert_ne!(recommended_id, pending.id.to_string());
611 }
612}