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)]
14pub struct Quote {
15 pub id: Uuid,
16 pub building_id: Uuid,
17 pub contractor_id: Uuid,
18 pub project_title: String,
19 pub project_description: String,
20 pub work_category: Option<String>,
21
22 pub amount_excl_vat: Option<Decimal>,
24 pub vat_rate: Option<Decimal>,
25 pub amount_incl_vat: Option<Decimal>,
26 pub validity_date: Option<DateTime<Utc>>,
27 pub estimated_start_date: Option<DateTime<Utc>>,
28 pub estimated_duration_days: Option<i32>,
29
30 pub warranty_years: i32, pub contractor_rating: Option<i32>, pub status: QuoteStatus,
36 pub requested_at: DateTime<Utc>,
37 pub submitted_at: Option<DateTime<Utc>>,
38 pub reviewed_at: Option<DateTime<Utc>>,
39 pub decision_at: Option<DateTime<Utc>>,
40 pub decision_by: Option<Uuid>, pub decision_notes: Option<String>,
42
43 pub created_at: DateTime<Utc>,
45 pub updated_at: DateTime<Utc>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
49pub enum QuoteStatus {
50 Requested, Received, UnderReview, Accepted, Rejected, Expired, Withdrawn, }
58
59impl QuoteStatus {
60 pub fn to_sql(&self) -> &'static str {
61 match self {
62 QuoteStatus::Requested => "Requested",
63 QuoteStatus::Received => "Received",
64 QuoteStatus::UnderReview => "UnderReview",
65 QuoteStatus::Accepted => "Accepted",
66 QuoteStatus::Rejected => "Rejected",
67 QuoteStatus::Expired => "Expired",
68 QuoteStatus::Withdrawn => "Withdrawn",
69 }
70 }
71
72 pub fn from_sql(s: &str) -> Result<Self, String> {
73 match s {
74 "Requested" => Ok(QuoteStatus::Requested),
75 "Received" => Ok(QuoteStatus::Received),
76 "UnderReview" => Ok(QuoteStatus::UnderReview),
77 "Accepted" => Ok(QuoteStatus::Accepted),
78 "Rejected" => Ok(QuoteStatus::Rejected),
79 "Expired" => Ok(QuoteStatus::Expired),
80 "Withdrawn" => Ok(QuoteStatus::Withdrawn),
81 _ => Err(format!("Invalid quote status: {}", s)),
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
89pub struct QuoteScore {
90 pub quote_id: Uuid,
91 pub total_score: f32, pub price_score: f32, pub delay_score: f32, pub warranty_score: f32, pub reputation_score: f32, }
97
98#[derive(Debug, Clone, PartialEq)]
100pub struct QuoteSubmission {
101 pub amount_excl_vat: Decimal,
102 pub vat_rate: Decimal,
103 pub validity_date: DateTime<Utc>,
104 pub estimated_duration_days: i32,
105 pub warranty_years: i32,
106}
107
108impl Quote {
109 pub fn new(
111 building_id: Uuid,
112 contractor_id: Uuid,
113 project_title: String,
114 project_description: String,
115 work_category: Option<String>,
116 warranty_years: i32,
117 ) -> Result<Self, String> {
118 if project_title.is_empty() {
119 return Err("Project title cannot be empty".to_string());
120 }
121 if warranty_years < 0 {
122 return Err("Warranty years cannot be negative".to_string());
123 }
124
125 let now = Utc::now();
126
127 Ok(Self {
128 id: Uuid::new_v4(),
129 building_id,
130 contractor_id,
131 project_title,
132 project_description,
133 work_category,
134 amount_excl_vat: None,
135 vat_rate: None,
136 amount_incl_vat: None,
137 validity_date: None,
138 estimated_start_date: None,
139 estimated_duration_days: None,
140 warranty_years,
141 contractor_rating: None,
142 status: QuoteStatus::Requested,
143 requested_at: now,
144 submitted_at: None,
145 reviewed_at: None,
146 decision_at: None,
147 decision_by: None,
148 decision_notes: None,
149 created_at: now,
150 updated_at: now,
151 })
152 }
153
154 pub fn submit(&mut self, pricing: Option<QuoteSubmission>) -> Result<(), String> {
162 if self.status != QuoteStatus::Requested {
163 return Err(format!(
164 "Cannot submit quote with status: {:?}",
165 self.status
166 ));
167 }
168
169 match pricing {
170 Some(p) => self.apply_pricing(p)?,
171 None => {
172 if self.amount_excl_vat.is_none() {
173 return Err("Quote has no price data — provide pricing to submit".to_string());
174 }
175 }
176 }
177
178 self.status = QuoteStatus::Received;
179 self.submitted_at = Some(Utc::now());
180 self.updated_at = Utc::now();
181 Ok(())
182 }
183
184 pub fn set_initial_pricing(&mut self, pricing: QuoteSubmission) -> Result<(), String> {
190 if self.status != QuoteStatus::Requested {
191 return Err(format!(
192 "Cannot set pricing on quote with status: {:?}",
193 self.status
194 ));
195 }
196 self.apply_pricing(pricing)
197 }
198
199 fn apply_pricing(&mut self, p: QuoteSubmission) -> Result<(), String> {
200 if p.amount_excl_vat <= Decimal::ZERO {
201 return Err("Amount must be greater than 0".to_string());
202 }
203 if p.estimated_duration_days <= 0 {
204 return Err("Estimated duration must be greater than 0 days".to_string());
205 }
206 if p.warranty_years < 0 {
207 return Err("Warranty years cannot be negative".to_string());
208 }
209 if p.validity_date <= Utc::now() {
210 return Err("Validity date must be in the future".to_string());
211 }
212
213 self.amount_excl_vat = Some(p.amount_excl_vat);
214 self.vat_rate = Some(p.vat_rate);
215 self.amount_incl_vat = Some(p.amount_excl_vat * (Decimal::ONE + p.vat_rate));
216 self.validity_date = Some(p.validity_date);
217 self.estimated_duration_days = Some(p.estimated_duration_days);
218 self.warranty_years = p.warranty_years;
219 Ok(())
220 }
221
222 pub fn start_review(&mut self) -> Result<(), String> {
224 if self.status != QuoteStatus::Received {
225 return Err(format!(
226 "Cannot review quote with status: {:?}",
227 self.status
228 ));
229 }
230 self.status = QuoteStatus::UnderReview;
231 self.reviewed_at = Some(Utc::now());
232 self.updated_at = Utc::now();
233 Ok(())
234 }
235
236 pub fn accept(
238 &mut self,
239 decision_by: Uuid,
240 decision_notes: Option<String>,
241 ) -> Result<(), String> {
242 if self.status != QuoteStatus::UnderReview && self.status != QuoteStatus::Received {
243 return Err(format!(
244 "Cannot accept quote with status: {:?}",
245 self.status
246 ));
247 }
248 if self.is_expired() {
249 return Err("Cannot accept expired quote".to_string());
250 }
251 self.status = QuoteStatus::Accepted;
252 self.decision_at = Some(Utc::now());
253 self.decision_by = Some(decision_by);
254 self.decision_notes = decision_notes;
255 self.updated_at = Utc::now();
256 Ok(())
257 }
258
259 pub fn reject(
261 &mut self,
262 decision_by: Uuid,
263 decision_notes: Option<String>,
264 ) -> Result<(), String> {
265 if self.status == QuoteStatus::Accepted {
266 return Err("Cannot reject already accepted quote".to_string());
267 }
268 self.status = QuoteStatus::Rejected;
269 self.decision_at = Some(Utc::now());
270 self.decision_by = Some(decision_by);
271 self.decision_notes = decision_notes;
272 self.updated_at = Utc::now();
273 Ok(())
274 }
275
276 pub fn withdraw(&mut self) -> Result<(), String> {
278 if self.status == QuoteStatus::Accepted {
279 return Err("Cannot withdraw accepted quote".to_string());
280 }
281 if self.status == QuoteStatus::Rejected {
282 return Err("Cannot withdraw rejected quote".to_string());
283 }
284 self.status = QuoteStatus::Withdrawn;
285 self.updated_at = Utc::now();
286 Ok(())
287 }
288
289 pub fn is_expired(&self) -> bool {
291 self.validity_date.is_some_and(|d| Utc::now() > d)
294 }
295
296 pub fn mark_expired(&mut self) -> Result<(), String> {
298 if !self.is_expired() {
299 return Err("Quote is not yet expired".to_string());
300 }
301 if self.status == QuoteStatus::Accepted {
302 return Err("Cannot expire accepted quote".to_string());
303 }
304 self.status = QuoteStatus::Expired;
305 self.updated_at = Utc::now();
306 Ok(())
307 }
308
309 pub fn set_contractor_rating(&mut self, rating: i32) -> Result<(), String> {
311 if rating < 0 || rating > 100 {
312 return Err("Contractor rating must be between 0 and 100".to_string());
313 }
314 self.contractor_rating = Some(rating);
315 self.updated_at = Utc::now();
316 Ok(())
317 }
318
319 pub fn calculate_score(
323 &self,
324 min_price: Decimal,
325 max_price: Decimal,
326 min_duration: i32,
327 max_duration: i32,
328 max_warranty: i32,
329 ) -> Result<QuoteScore, String> {
330 if max_price <= min_price {
331 return Err("Invalid price range for scoring".to_string());
332 }
333 if max_duration <= min_duration {
334 return Err("Invalid duration range for scoring".to_string());
335 }
336 if max_warranty <= 0 {
337 return Err("Max warranty must be positive".to_string());
338 }
339 let amount_incl_vat = self
340 .amount_incl_vat
341 .ok_or("Quote has no price data (not yet submitted)")?;
342 let estimated_duration_days = self
343 .estimated_duration_days
344 .ok_or("Quote has no price data (not yet submitted)")?;
345
346 let price_score = if amount_incl_vat <= min_price {
348 100.0
349 } else if amount_incl_vat >= max_price {
350 0.0
351 } else {
352 let price_range = max_price - min_price;
353 let price_delta = max_price - amount_incl_vat;
354 (price_delta / price_range * Decimal::from(100))
355 .to_f32()
356 .unwrap_or(0.0)
357 };
358
359 let delay_score = if estimated_duration_days <= min_duration {
361 100.0
362 } else if estimated_duration_days >= max_duration {
363 0.0
364 } else {
365 let duration_range = (max_duration - min_duration) as f32;
366 let duration_delta = (max_duration - estimated_duration_days) as f32;
367 (duration_delta / duration_range) * 100.0
368 };
369
370 let warranty_score = if max_warranty == 0 {
372 0.0
373 } else {
374 ((self.warranty_years as f32 / max_warranty as f32) * 100.0).min(100.0)
375 };
376
377 let reputation_score = self.contractor_rating.unwrap_or(50) as f32;
379
380 let total_score = (price_score * 0.4)
382 + (delay_score * 0.3)
383 + (warranty_score * 0.2)
384 + (reputation_score * 0.1);
385
386 Ok(QuoteScore {
387 quote_id: self.id,
388 total_score,
389 price_score,
390 delay_score,
391 warranty_score,
392 reputation_score,
393 })
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use rust_decimal::Decimal;
401 use std::str::FromStr;
402
403 macro_rules! dec {
405 ($val:expr) => {
406 Decimal::from_str(stringify!($val)).unwrap()
407 };
408 }
409
410 fn test_submission(
411 amount: Decimal,
412 duration_days: i32,
413 warranty_years: i32,
414 ) -> QuoteSubmission {
415 QuoteSubmission {
416 amount_excl_vat: amount,
417 vat_rate: dec!(0.21), validity_date: Utc::now() + chrono::Duration::days(30),
419 estimated_duration_days: duration_days,
420 warranty_years,
421 }
422 }
423
424 #[test]
425 fn test_create_quote_success() {
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 "Roof Repair".to_string(),
433 "Repair leaking roof tiles".to_string(),
434 Some("roofing".to_string()),
435 10, );
437
438 assert!(quote.is_ok());
439 let quote = quote.unwrap();
440 assert_eq!(quote.status, QuoteStatus::Requested);
441 assert_eq!(quote.amount_incl_vat, None);
443 assert_eq!(quote.estimated_duration_days, None);
444 assert_eq!(quote.warranty_years, 10);
445 }
446
447 #[test]
448 fn test_create_quote_validation_failures() {
449 let building_id = Uuid::new_v4();
450 let contractor_id = Uuid::new_v4();
451
452 let result = Quote::new(
454 building_id,
455 contractor_id,
456 "".to_string(),
457 "Description".to_string(),
458 None,
459 10,
460 );
461 assert!(result.is_err());
462 assert_eq!(result.unwrap_err(), "Project title cannot be empty");
463 }
464
465 #[test]
466 fn test_submit_quote_validation_failures() {
467 let mut quote = create_test_quote();
471
472 let mut zero_amount = test_submission(dec!(0.00), 14, 10);
473 zero_amount.amount_excl_vat = dec!(0.00);
474 let result = quote.submit(Some(zero_amount));
475 assert!(result.is_err());
476
477 let mut past_validity = test_submission(dec!(5000.00), 14, 10);
478 past_validity.validity_date = Utc::now() - chrono::Duration::days(1);
479 let result = quote.submit(Some(past_validity));
480 assert!(result.is_err());
481 }
482
483 #[test]
484 fn test_submit_without_pricing_requires_existing_price() {
485 let mut quote = create_test_quote();
488 let result = quote.submit(None);
489 assert!(result.is_err());
490 assert_eq!(
491 result.unwrap_err(),
492 "Quote has no price data — provide pricing to submit"
493 );
494 }
495
496 #[test]
497 fn test_quote_workflow_submit() {
498 let mut quote = create_test_quote();
499 assert_eq!(quote.status, QuoteStatus::Requested);
500
501 let result = quote.submit(Some(test_submission(dec!(5000.00), 14, 10)));
502 assert!(result.is_ok());
503 assert_eq!(quote.status, QuoteStatus::Received);
504 assert!(quote.submitted_at.is_some());
505 assert_eq!(quote.amount_incl_vat, Some(dec!(6050.00))); }
507
508 #[test]
509 fn test_quote_workflow_review() {
510 let mut quote = create_test_quote();
511 quote
512 .submit(Some(test_submission(dec!(5000.00), 14, 10)))
513 .unwrap();
514
515 let result = quote.start_review();
516 assert!(result.is_ok());
517 assert_eq!(quote.status, QuoteStatus::UnderReview);
518 assert!(quote.reviewed_at.is_some());
519 }
520
521 #[test]
522 fn test_quote_workflow_accept() {
523 let mut quote = create_test_quote();
524 quote
525 .submit(Some(test_submission(dec!(5000.00), 14, 10)))
526 .unwrap();
527 quote.start_review().unwrap();
528
529 let decision_by = Uuid::new_v4();
530 let result = quote.accept(decision_by, Some("Best value for money".to_string()));
531 assert!(result.is_ok());
532 assert_eq!(quote.status, QuoteStatus::Accepted);
533 assert_eq!(quote.decision_by, Some(decision_by));
534 assert_eq!(
535 quote.decision_notes,
536 Some("Best value for money".to_string())
537 );
538 }
539
540 #[test]
541 fn test_quote_workflow_reject() {
542 let mut quote = create_test_quote();
543 quote
544 .submit(Some(test_submission(dec!(5000.00), 14, 10)))
545 .unwrap();
546
547 let decision_by = Uuid::new_v4();
548 let result = quote.reject(decision_by, Some("Price too high".to_string()));
549 assert!(result.is_ok());
550 assert_eq!(quote.status, QuoteStatus::Rejected);
551 }
552
553 #[test]
554 fn test_quote_cannot_reject_accepted() {
555 let mut quote = create_test_quote();
556 quote
557 .submit(Some(test_submission(dec!(5000.00), 14, 10)))
558 .unwrap();
559 quote.start_review().unwrap();
560 quote.accept(Uuid::new_v4(), None).unwrap();
561
562 let result = quote.reject(Uuid::new_v4(), None);
563 assert!(result.is_err());
564 assert_eq!(result.unwrap_err(), "Cannot reject already accepted quote");
565 }
566
567 #[test]
568 fn test_quote_withdraw() {
569 let mut quote = create_test_quote();
570 quote
571 .submit(Some(test_submission(dec!(5000.00), 14, 10)))
572 .unwrap();
573
574 let result = quote.withdraw();
575 assert!(result.is_ok());
576 assert_eq!(quote.status, QuoteStatus::Withdrawn);
577 }
578
579 #[test]
580 fn test_quote_scoring_algorithm() {
581 let mut quote1 = create_test_quote_with_details(dec!(5000.00), 14, 10, Some(80));
582 let mut quote2 = create_test_quote_with_details(dec!(7000.00), 10, 2, Some(90));
583 let mut quote3 = create_test_quote_with_details(dec!(6000.00), 12, 5, Some(70));
584
585 quote1
586 .submit(Some(test_submission(dec!(5000.00), 14, 10)))
587 .unwrap();
588 quote2
589 .submit(Some(test_submission(dec!(7000.00), 10, 2)))
590 .unwrap();
591 quote3
592 .submit(Some(test_submission(dec!(6000.00), 12, 5)))
593 .unwrap();
594
595 let score1 = quote1
598 .calculate_score(dec!(6050.00), dec!(8470.00), 10, 14, 10)
599 .unwrap();
600 let score2 = quote2
601 .calculate_score(dec!(6050.00), dec!(8470.00), 10, 14, 10)
602 .unwrap();
603 let score3 = quote3
604 .calculate_score(dec!(6050.00), dec!(8470.00), 10, 14, 10)
605 .unwrap();
606
607 assert!(score1.total_score > score3.total_score);
612 assert!(score3.total_score > score2.total_score);
613 assert!(score1.total_score > 60.0); }
615
616 #[test]
617 fn test_quote_not_yet_submitted_cannot_be_scored() {
618 let quote = create_test_quote();
619 let result = quote.calculate_score(dec!(1000.00), dec!(2000.00), 5, 10, 10);
620 assert!(result.is_err());
621 }
622
623 #[test]
624 fn test_quote_expiration() {
625 let mut quote = create_test_quote();
626 assert!(!quote.is_expired());
628
629 quote
630 .submit(Some(test_submission(dec!(5000.00), 14, 10)))
631 .unwrap();
632 assert!(!quote.is_expired());
633
634 quote.validity_date = Some(Utc::now() - chrono::Duration::seconds(1));
636 assert!(quote.is_expired());
637
638 let result = quote.mark_expired();
639 assert!(result.is_ok());
640 assert_eq!(quote.status, QuoteStatus::Expired);
641 }
642
643 #[test]
644 fn test_contractor_rating_validation() {
645 let mut quote = create_test_quote();
646
647 let result = quote.set_contractor_rating(150);
648 assert!(result.is_err());
649 assert_eq!(
650 result.unwrap_err(),
651 "Contractor rating must be between 0 and 100"
652 );
653
654 let result = quote.set_contractor_rating(85);
655 assert!(result.is_ok());
656 assert_eq!(quote.contractor_rating, Some(85));
657 }
658
659 fn create_test_quote() -> Quote {
662 let building_id = Uuid::new_v4();
663 let contractor_id = Uuid::new_v4();
664
665 Quote::new(
666 building_id,
667 contractor_id,
668 "Test Project".to_string(),
669 "Test Description".to_string(),
670 Some("roofing".to_string()),
671 10,
672 )
673 .unwrap()
674 }
675
676 fn create_test_quote_with_details(
677 amount: Decimal,
678 _duration_days: i32,
679 warranty_years: i32,
680 rating: Option<i32>,
681 ) -> Quote {
682 let _ = amount; let building_id = Uuid::new_v4();
684 let contractor_id = Uuid::new_v4();
685
686 let mut quote = Quote::new(
687 building_id,
688 contractor_id,
689 "Test Project".to_string(),
690 "Test Description".to_string(),
691 Some("roofing".to_string()),
692 warranty_years,
693 )
694 .unwrap();
695
696 if let Some(r) = rating {
697 quote.set_contractor_rating(r).unwrap();
698 }
699
700 quote
701 }
702}