koprogo_api/application/use_cases/
quote_use_cases.rs1use 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 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("e).await?;
55 Ok(QuoteResponseDto::from(created))
56 }
57
58 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("e).await?;
69 Ok(QuoteResponseDto::from(updated))
70 }
71
72 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("e).await?;
83 Ok(QuoteResponseDto::from(updated))
84 }
85
86 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("e).await?;
102 Ok(QuoteResponseDto::from(updated))
103 }
104
105 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("e).await?;
121 Ok(QuoteResponseDto::from(updated))
122 }
123
124 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("e).await?;
135 Ok(QuoteResponseDto::from(updated))
136 }
137
138 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 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 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 let building_id = quotes[0].building_id;
170 let project_title = quotes[0].project_title.clone();
171 for quote in "es {
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 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 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 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 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, })
241 .collect();
242
243 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 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 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 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 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 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 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("e).await?;
323 Ok(QuoteResponseDto::from(updated))
324 }
325
326 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("e).await?;
335 count += 1;
336 }
337 }
338
339 Ok(count)
340 }
341
342 pub async fn delete_quote(&self, quote_id: Uuid) -> Result<bool, String> {
344 self.repository.delete(quote_id).await
345 }
346
347 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 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 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}