koprogo_api/domain/entities/
quote.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::prelude::ToPrimitive;
3use rust_decimal::Decimal;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Quote for contractor work (Belgian legal requirement: 3 quotes for works >5000€)
8#[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    // Quote details
17    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    // Scoring factors (Belgian best practices)
25    pub warranty_years: i32, // 2 years (apparent defects), 10 years (structural)
26    pub contractor_rating: Option<i32>, // 0-100 based on history
27
28    // Status & workflow
29    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>, // User who made decision
35    pub decision_notes: Option<String>,
36
37    // Audit trail
38    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,   // Quote requested from contractor
45    Received,    // Contractor submitted quote
46    UnderReview, // Syndic reviewing/comparing quotes
47    Accepted,    // Quote accepted (winner)
48    Rejected,    // Quote rejected (loser or unqualified)
49    Expired,     // Validity date passed
50    Withdrawn,   // Contractor withdrew quote
51}
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/// Automatic scoring result (Belgian best practices)
81/// Scoring algorithm: price (40%), delay (30%), warranty (20%), reputation (10%)
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
83pub struct QuoteScore {
84    pub quote_id: Uuid,
85    pub total_score: f32,      // 0-100
86    pub price_score: f32,      // 0-100 (lower price = higher score)
87    pub delay_score: f32,      // 0-100 (shorter delay = higher score)
88    pub warranty_score: f32,   // 0-100 (longer warranty = higher score)
89    pub reputation_score: f32, // 0-100 (contractor rating)
90}
91
92impl Quote {
93    /// Create new quote request
94    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    /// Submit quote (contractor action)
151    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    /// Mark quote under review (Syndic action)
165    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    /// Accept quote (winning bid)
179    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    /// Reject quote (losing bid or unqualified)
202    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    /// Withdraw quote (contractor action)
219    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    /// Check if quote is expired
232    pub fn is_expired(&self) -> bool {
233        Utc::now() > self.validity_date
234    }
235
236    /// Mark quote as expired (background job)
237    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    /// Update contractor rating (from historical data)
250    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    /// Calculate automatic score (Belgian best practices)
260    /// Algorithm: price (40%), delay (30%), warranty (20%), reputation (10%)
261    /// Returns QuoteScore with breakdown
262    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        // Price score: lower price = higher score (inverted normalization)
281        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        // Delay score: shorter duration = higher score (inverted normalization)
294        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        // Warranty score: longer warranty = higher score (direct normalization)
305        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        // Reputation score: contractor rating (0-100)
312        let reputation_score = self.contractor_rating.unwrap_or(50) as f32;
313
314        // Weighted total score: price (40%), delay (30%), warranty (20%), reputation (10%)
315        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    // Helper macro since dec! is not available in rust_decimal 1.36
338    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), // 21% VAT (Belgian standard)
357            validity_date,
358            14, // 14 days estimated duration
359            10, // 10 years warranty (structural work)
360        );
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)); // 5000 * 1.21
366        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        // Empty title
377        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        // Zero amount
392        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        // Past validity date
406        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        // Score with min/max ranges (must use amount_incl_vat since quotes store VAT-included prices)
504        // quote1: 5000 * 1.21 = 6050, quote2: 7000 * 1.21 = 8470, quote3: 6000 * 1.21 = 7260
505        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        // Quote1: lowest price (100 * 0.4) + longest delay (0 * 0.3) + best warranty (100 * 0.2) + good reputation (80 * 0.1) = 68
516        // Quote2: highest price (0 * 0.4) + shortest delay (100 * 0.3) + low warranty (20 * 0.2) + best reputation (90 * 0.1) = 43
517        // Quote3: mid price (50 * 0.4) + mid delay (50 * 0.3) + mid warranty (50 * 0.2) + low reputation (70 * 0.1) = 52
518
519        assert!(score1.total_score > score3.total_score);
520        assert!(score3.total_score > score2.total_score);
521        assert!(score1.total_score > 60.0); // Quote1 should be best (price + warranty)
522    }
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); // Already expired
529
530        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), // Start with future date
538            14,
539            10,
540        )
541        .unwrap();
542
543        // Manually set validity_date to past
544        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    // Helper functions
570
571    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}