1use chrono::{DateTime, Utc};
2use rust_decimal::prelude::ToPrimitive;
3use rust_decimal::Decimal;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct Quote {
10 pub id: Uuid,
11 pub building_id: Uuid,
12 pub contractor_id: Uuid,
13 pub project_title: String,
14 pub project_description: String,
15
16 pub amount_excl_vat: Decimal,
18 pub vat_rate: Decimal,
19 pub amount_incl_vat: Decimal,
20 pub validity_date: DateTime<Utc>,
21 pub estimated_start_date: Option<DateTime<Utc>>,
22 pub estimated_duration_days: i32,
23
24 pub warranty_years: i32, pub contractor_rating: Option<i32>, pub status: QuoteStatus,
30 pub requested_at: DateTime<Utc>,
31 pub submitted_at: Option<DateTime<Utc>>,
32 pub reviewed_at: Option<DateTime<Utc>>,
33 pub decision_at: Option<DateTime<Utc>>,
34 pub decision_by: Option<Uuid>, pub decision_notes: Option<String>,
36
37 pub created_at: DateTime<Utc>,
39 pub updated_at: DateTime<Utc>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub enum QuoteStatus {
44 Requested, Received, UnderReview, Accepted, Rejected, Expired, Withdrawn, }
52
53impl QuoteStatus {
54 pub fn to_sql(&self) -> &'static str {
55 match self {
56 QuoteStatus::Requested => "Requested",
57 QuoteStatus::Received => "Received",
58 QuoteStatus::UnderReview => "UnderReview",
59 QuoteStatus::Accepted => "Accepted",
60 QuoteStatus::Rejected => "Rejected",
61 QuoteStatus::Expired => "Expired",
62 QuoteStatus::Withdrawn => "Withdrawn",
63 }
64 }
65
66 pub fn from_sql(s: &str) -> Result<Self, String> {
67 match s {
68 "Requested" => Ok(QuoteStatus::Requested),
69 "Received" => Ok(QuoteStatus::Received),
70 "UnderReview" => Ok(QuoteStatus::UnderReview),
71 "Accepted" => Ok(QuoteStatus::Accepted),
72 "Rejected" => Ok(QuoteStatus::Rejected),
73 "Expired" => Ok(QuoteStatus::Expired),
74 "Withdrawn" => Ok(QuoteStatus::Withdrawn),
75 _ => Err(format!("Invalid quote status: {}", s)),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
83pub struct QuoteScore {
84 pub quote_id: Uuid,
85 pub total_score: f32, pub price_score: f32, pub delay_score: f32, pub warranty_score: f32, pub reputation_score: f32, }
91
92impl Quote {
93 pub fn new(
95 building_id: Uuid,
96 contractor_id: Uuid,
97 project_title: String,
98 project_description: String,
99 amount_excl_vat: Decimal,
100 vat_rate: Decimal,
101 validity_date: DateTime<Utc>,
102 estimated_duration_days: i32,
103 warranty_years: i32,
104 ) -> Result<Self, String> {
105 if project_title.is_empty() {
106 return Err("Project title cannot be empty".to_string());
107 }
108 if amount_excl_vat <= Decimal::ZERO {
109 return Err("Amount must be greater than 0".to_string());
110 }
111 if estimated_duration_days <= 0 {
112 return Err("Estimated duration must be greater than 0 days".to_string());
113 }
114 if warranty_years < 0 {
115 return Err("Warranty years cannot be negative".to_string());
116 }
117 if validity_date <= Utc::now() {
118 return Err("Validity date must be in the future".to_string());
119 }
120
121 let amount_incl_vat = amount_excl_vat * (Decimal::ONE + vat_rate);
122 let now = Utc::now();
123
124 Ok(Self {
125 id: Uuid::new_v4(),
126 building_id,
127 contractor_id,
128 project_title,
129 project_description,
130 amount_excl_vat,
131 vat_rate,
132 amount_incl_vat,
133 validity_date,
134 estimated_start_date: None,
135 estimated_duration_days,
136 warranty_years,
137 contractor_rating: None,
138 status: QuoteStatus::Requested,
139 requested_at: now,
140 submitted_at: None,
141 reviewed_at: None,
142 decision_at: None,
143 decision_by: None,
144 decision_notes: None,
145 created_at: now,
146 updated_at: now,
147 })
148 }
149
150 pub fn submit(&mut self) -> Result<(), String> {
152 if self.status != QuoteStatus::Requested {
153 return Err(format!(
154 "Cannot submit quote with status: {:?}",
155 self.status
156 ));
157 }
158 self.status = QuoteStatus::Received;
159 self.submitted_at = Some(Utc::now());
160 self.updated_at = Utc::now();
161 Ok(())
162 }
163
164 pub fn start_review(&mut self) -> Result<(), String> {
166 if self.status != QuoteStatus::Received {
167 return Err(format!(
168 "Cannot review quote with status: {:?}",
169 self.status
170 ));
171 }
172 self.status = QuoteStatus::UnderReview;
173 self.reviewed_at = Some(Utc::now());
174 self.updated_at = Utc::now();
175 Ok(())
176 }
177
178 pub fn accept(
180 &mut self,
181 decision_by: Uuid,
182 decision_notes: Option<String>,
183 ) -> Result<(), String> {
184 if self.status != QuoteStatus::UnderReview && self.status != QuoteStatus::Received {
185 return Err(format!(
186 "Cannot accept quote with status: {:?}",
187 self.status
188 ));
189 }
190 if self.is_expired() {
191 return Err("Cannot accept expired quote".to_string());
192 }
193 self.status = QuoteStatus::Accepted;
194 self.decision_at = Some(Utc::now());
195 self.decision_by = Some(decision_by);
196 self.decision_notes = decision_notes;
197 self.updated_at = Utc::now();
198 Ok(())
199 }
200
201 pub fn reject(
203 &mut self,
204 decision_by: Uuid,
205 decision_notes: Option<String>,
206 ) -> Result<(), String> {
207 if self.status == QuoteStatus::Accepted {
208 return Err("Cannot reject already accepted quote".to_string());
209 }
210 self.status = QuoteStatus::Rejected;
211 self.decision_at = Some(Utc::now());
212 self.decision_by = Some(decision_by);
213 self.decision_notes = decision_notes;
214 self.updated_at = Utc::now();
215 Ok(())
216 }
217
218 pub fn withdraw(&mut self) -> Result<(), String> {
220 if self.status == QuoteStatus::Accepted {
221 return Err("Cannot withdraw accepted quote".to_string());
222 }
223 if self.status == QuoteStatus::Rejected {
224 return Err("Cannot withdraw rejected quote".to_string());
225 }
226 self.status = QuoteStatus::Withdrawn;
227 self.updated_at = Utc::now();
228 Ok(())
229 }
230
231 pub fn is_expired(&self) -> bool {
233 Utc::now() > self.validity_date
234 }
235
236 pub fn mark_expired(&mut self) -> Result<(), String> {
238 if !self.is_expired() {
239 return Err("Quote is not yet expired".to_string());
240 }
241 if self.status == QuoteStatus::Accepted {
242 return Err("Cannot expire accepted quote".to_string());
243 }
244 self.status = QuoteStatus::Expired;
245 self.updated_at = Utc::now();
246 Ok(())
247 }
248
249 pub fn set_contractor_rating(&mut self, rating: i32) -> Result<(), String> {
251 if rating < 0 || rating > 100 {
252 return Err("Contractor rating must be between 0 and 100".to_string());
253 }
254 self.contractor_rating = Some(rating);
255 self.updated_at = Utc::now();
256 Ok(())
257 }
258
259 pub fn calculate_score(
263 &self,
264 min_price: Decimal,
265 max_price: Decimal,
266 min_duration: i32,
267 max_duration: i32,
268 max_warranty: i32,
269 ) -> Result<QuoteScore, String> {
270 if max_price <= min_price {
271 return Err("Invalid price range for scoring".to_string());
272 }
273 if max_duration <= min_duration {
274 return Err("Invalid duration range for scoring".to_string());
275 }
276 if max_warranty <= 0 {
277 return Err("Max warranty must be positive".to_string());
278 }
279
280 let price_score = if self.amount_incl_vat <= min_price {
282 100.0
283 } else if self.amount_incl_vat >= max_price {
284 0.0
285 } else {
286 let price_range = max_price - min_price;
287 let price_delta = max_price - self.amount_incl_vat;
288 (price_delta / price_range * Decimal::from(100))
289 .to_f32()
290 .unwrap_or(0.0)
291 };
292
293 let delay_score = if self.estimated_duration_days <= min_duration {
295 100.0
296 } else if self.estimated_duration_days >= max_duration {
297 0.0
298 } else {
299 let duration_range = (max_duration - min_duration) as f32;
300 let duration_delta = (max_duration - self.estimated_duration_days) as f32;
301 (duration_delta / duration_range) * 100.0
302 };
303
304 let warranty_score = if max_warranty == 0 {
306 0.0
307 } else {
308 ((self.warranty_years as f32 / max_warranty as f32) * 100.0).min(100.0)
309 };
310
311 let reputation_score = self.contractor_rating.unwrap_or(50) as f32;
313
314 let total_score = (price_score * 0.4)
316 + (delay_score * 0.3)
317 + (warranty_score * 0.2)
318 + (reputation_score * 0.1);
319
320 Ok(QuoteScore {
321 quote_id: self.id,
322 total_score,
323 price_score,
324 delay_score,
325 warranty_score,
326 reputation_score,
327 })
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use rust_decimal::Decimal;
335 use std::str::FromStr;
336
337 macro_rules! dec {
339 ($val:expr) => {
340 Decimal::from_str(stringify!($val)).unwrap()
341 };
342 }
343
344 #[test]
345 fn test_create_quote_success() {
346 let building_id = Uuid::new_v4();
347 let contractor_id = Uuid::new_v4();
348 let validity_date = Utc::now() + chrono::Duration::days(30);
349
350 let quote = Quote::new(
351 building_id,
352 contractor_id,
353 "Roof Repair".to_string(),
354 "Repair leaking roof tiles".to_string(),
355 dec!(5000.00),
356 dec!(0.21), validity_date,
358 14, 10, );
361
362 assert!(quote.is_ok());
363 let quote = quote.unwrap();
364 assert_eq!(quote.status, QuoteStatus::Requested);
365 assert_eq!(quote.amount_incl_vat, dec!(6050.00)); assert_eq!(quote.estimated_duration_days, 14);
367 assert_eq!(quote.warranty_years, 10);
368 }
369
370 #[test]
371 fn test_create_quote_validation_failures() {
372 let building_id = Uuid::new_v4();
373 let contractor_id = Uuid::new_v4();
374 let validity_date = Utc::now() + chrono::Duration::days(30);
375
376 let result = Quote::new(
378 building_id,
379 contractor_id,
380 "".to_string(),
381 "Description".to_string(),
382 dec!(5000.00),
383 dec!(0.21),
384 validity_date,
385 14,
386 10,
387 );
388 assert!(result.is_err());
389 assert_eq!(result.unwrap_err(), "Project title cannot be empty");
390
391 let result = Quote::new(
393 building_id,
394 contractor_id,
395 "Title".to_string(),
396 "Description".to_string(),
397 dec!(0.00),
398 dec!(0.21),
399 validity_date,
400 14,
401 10,
402 );
403 assert!(result.is_err());
404
405 let past_date = Utc::now() - chrono::Duration::days(1);
407 let result = Quote::new(
408 building_id,
409 contractor_id,
410 "Title".to_string(),
411 "Description".to_string(),
412 dec!(5000.00),
413 dec!(0.21),
414 past_date,
415 14,
416 10,
417 );
418 assert!(result.is_err());
419 }
420
421 #[test]
422 fn test_quote_workflow_submit() {
423 let mut quote = create_test_quote();
424 assert_eq!(quote.status, QuoteStatus::Requested);
425
426 let result = quote.submit();
427 assert!(result.is_ok());
428 assert_eq!(quote.status, QuoteStatus::Received);
429 assert!(quote.submitted_at.is_some());
430 }
431
432 #[test]
433 fn test_quote_workflow_review() {
434 let mut quote = create_test_quote();
435 quote.submit().unwrap();
436
437 let result = quote.start_review();
438 assert!(result.is_ok());
439 assert_eq!(quote.status, QuoteStatus::UnderReview);
440 assert!(quote.reviewed_at.is_some());
441 }
442
443 #[test]
444 fn test_quote_workflow_accept() {
445 let mut quote = create_test_quote();
446 quote.submit().unwrap();
447 quote.start_review().unwrap();
448
449 let decision_by = Uuid::new_v4();
450 let result = quote.accept(decision_by, Some("Best value for money".to_string()));
451 assert!(result.is_ok());
452 assert_eq!(quote.status, QuoteStatus::Accepted);
453 assert_eq!(quote.decision_by, Some(decision_by));
454 assert_eq!(
455 quote.decision_notes,
456 Some("Best value for money".to_string())
457 );
458 }
459
460 #[test]
461 fn test_quote_workflow_reject() {
462 let mut quote = create_test_quote();
463 quote.submit().unwrap();
464
465 let decision_by = Uuid::new_v4();
466 let result = quote.reject(decision_by, Some("Price too high".to_string()));
467 assert!(result.is_ok());
468 assert_eq!(quote.status, QuoteStatus::Rejected);
469 }
470
471 #[test]
472 fn test_quote_cannot_reject_accepted() {
473 let mut quote = create_test_quote();
474 quote.submit().unwrap();
475 quote.start_review().unwrap();
476 quote.accept(Uuid::new_v4(), None).unwrap();
477
478 let result = quote.reject(Uuid::new_v4(), None);
479 assert!(result.is_err());
480 assert_eq!(result.unwrap_err(), "Cannot reject already accepted quote");
481 }
482
483 #[test]
484 fn test_quote_withdraw() {
485 let mut quote = create_test_quote();
486 quote.submit().unwrap();
487
488 let result = quote.withdraw();
489 assert!(result.is_ok());
490 assert_eq!(quote.status, QuoteStatus::Withdrawn);
491 }
492
493 #[test]
494 fn test_quote_scoring_algorithm() {
495 let mut quote1 = create_test_quote_with_details(dec!(5000.00), 14, 10, Some(80));
496 let mut quote2 = create_test_quote_with_details(dec!(7000.00), 10, 2, Some(90));
497 let mut quote3 = create_test_quote_with_details(dec!(6000.00), 12, 5, Some(70));
498
499 quote1.submit().unwrap();
500 quote2.submit().unwrap();
501 quote3.submit().unwrap();
502
503 let score1 = quote1
506 .calculate_score(dec!(6050.00), dec!(8470.00), 10, 14, 10)
507 .unwrap();
508 let score2 = quote2
509 .calculate_score(dec!(6050.00), dec!(8470.00), 10, 14, 10)
510 .unwrap();
511 let score3 = quote3
512 .calculate_score(dec!(6050.00), dec!(8470.00), 10, 14, 10)
513 .unwrap();
514
515 assert!(score1.total_score > score3.total_score);
520 assert!(score3.total_score > score2.total_score);
521 assert!(score1.total_score > 60.0); }
523
524 #[test]
525 fn test_quote_expiration() {
526 let building_id = Uuid::new_v4();
527 let contractor_id = Uuid::new_v4();
528 let validity_date = Utc::now() - chrono::Duration::seconds(1); let mut quote = Quote::new(
531 building_id,
532 contractor_id,
533 "Test Project".to_string(),
534 "Description".to_string(),
535 dec!(5000.00),
536 dec!(0.21),
537 Utc::now() + chrono::Duration::days(30), 14,
539 10,
540 )
541 .unwrap();
542
543 quote.validity_date = validity_date;
545
546 assert!(quote.is_expired());
547
548 let result = quote.mark_expired();
549 assert!(result.is_ok());
550 assert_eq!(quote.status, QuoteStatus::Expired);
551 }
552
553 #[test]
554 fn test_contractor_rating_validation() {
555 let mut quote = create_test_quote();
556
557 let result = quote.set_contractor_rating(150);
558 assert!(result.is_err());
559 assert_eq!(
560 result.unwrap_err(),
561 "Contractor rating must be between 0 and 100"
562 );
563
564 let result = quote.set_contractor_rating(85);
565 assert!(result.is_ok());
566 assert_eq!(quote.contractor_rating, Some(85));
567 }
568
569 fn create_test_quote() -> Quote {
572 let building_id = Uuid::new_v4();
573 let contractor_id = Uuid::new_v4();
574 let validity_date = Utc::now() + chrono::Duration::days(30);
575
576 Quote::new(
577 building_id,
578 contractor_id,
579 "Test Project".to_string(),
580 "Test Description".to_string(),
581 dec!(5000.00),
582 dec!(0.21),
583 validity_date,
584 14,
585 10,
586 )
587 .unwrap()
588 }
589
590 fn create_test_quote_with_details(
591 amount: Decimal,
592 duration_days: i32,
593 warranty_years: i32,
594 rating: Option<i32>,
595 ) -> Quote {
596 let building_id = Uuid::new_v4();
597 let contractor_id = Uuid::new_v4();
598 let validity_date = Utc::now() + chrono::Duration::days(30);
599
600 let mut quote = Quote::new(
601 building_id,
602 contractor_id,
603 "Test Project".to_string(),
604 "Test Description".to_string(),
605 amount,
606 dec!(0.21),
607 validity_date,
608 duration_days,
609 warranty_years,
610 )
611 .unwrap();
612
613 if let Some(r) = rating {
614 quote.set_contractor_rating(r).unwrap();
615 }
616
617 quote
618 }
619}