Skip to main content

koprogo_api/domain/comptabilite/
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 professional best practice: 3 quotes for works >5000€)
8///
9/// 2-phase workflow: a quote is *requested* (title/description/category only —
10/// nobody knows the price yet) then *submitted* (contractor's actual pricing,
11/// via `submit()`). Price/terms fields are therefore `None` until the quote
12/// reaches `QuoteStatus::Received`.
13#[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    // Quote details — set at submission, not at request time.
23    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    // Scoring factors (Belgian best practices)
31    pub warranty_years: i32, // 2 years (apparent defects), 10 years (structural)
32    pub contractor_rating: Option<i32>, // 0-100 based on history
33
34    // Status & workflow
35    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>, // User who made decision
41    pub decision_notes: Option<String>,
42
43    // Audit trail
44    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,   // Quote requested from contractor
51    Received,    // Contractor submitted quote
52    UnderReview, // Syndic reviewing/comparing quotes
53    Accepted,    // Quote accepted (winner)
54    Rejected,    // Quote rejected (loser or unqualified)
55    Expired,     // Validity date passed
56    Withdrawn,   // Contractor withdrew quote
57}
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/// Automatic scoring result (Belgian best practices)
87/// Scoring algorithm: price (40%), delay (30%), warranty (20%), reputation (10%)
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
89pub struct QuoteScore {
90    pub quote_id: Uuid,
91    pub total_score: f32,      // 0-100
92    pub price_score: f32,      // 0-100 (lower price = higher score)
93    pub delay_score: f32,      // 0-100 (shorter delay = higher score)
94    pub warranty_score: f32,   // 0-100 (longer warranty = higher score)
95    pub reputation_score: f32, // 0-100 (contractor rating)
96}
97
98/// Pricing/terms carried by a quote submission — see [`Quote::submit`].
99#[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    /// Create new quote request — request phase only, no pricing (cf. struct docs).
110    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    /// Submit quote (contractor/syndic action) — moves Requested -> Received.
155    ///
156    /// `pricing` carries the contractor's actual price/terms and is validated
157    /// here (this is where those rules now live, moved from `new()`). Passing
158    /// `None` is only valid when the quote already carries price data (e.g.
159    /// it was created with pricing already known) — otherwise this errors,
160    /// since a `Received` quote without a price makes no sense.
161    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    /// Set pricing on a quote that is still `Requested` (does NOT transition
185    /// status — unlike `submit()`). Backward-compat escape hatch for callers
186    /// that already know the price when requesting the quote (e.g. a syndic
187    /// manually recording a quote received by phone/email/paper): the quote
188    /// still goes through the normal `submit()` step afterward.
189    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    /// Mark quote under review (Syndic action)
223    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    /// Accept quote (winning bid)
237    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    /// Reject quote (losing bid or unqualified)
260    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    /// Withdraw quote (contractor action)
277    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    /// Check if quote is expired
290    pub fn is_expired(&self) -> bool {
291        // No validity_date yet (not submitted) means "not expired" — there's
292        // nothing to expire before a price/validity has ever been set.
293        self.validity_date.is_some_and(|d| Utc::now() > d)
294    }
295
296    /// Mark quote as expired (background job)
297    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    /// Update contractor rating (from historical data)
310    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    /// Calculate automatic score (Belgian best practices)
320    /// Algorithm: price (40%), delay (30%), warranty (20%), reputation (10%)
321    /// Returns QuoteScore with breakdown
322    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        // Price score: lower price = higher score (inverted normalization)
347        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        // Delay score: shorter duration = higher score (inverted normalization)
360        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        // Warranty score: longer warranty = higher score (direct normalization)
371        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        // Reputation score: contractor rating (0-100)
378        let reputation_score = self.contractor_rating.unwrap_or(50) as f32;
379
380        // Weighted total score: price (40%), delay (30%), warranty (20%), reputation (10%)
381        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    // Helper macro since dec! is not available in rust_decimal 1.36
404    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), // 21% VAT (Belgian standard)
418            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, // 10 years warranty (structural work)
436        );
437
438        assert!(quote.is_ok());
439        let quote = quote.unwrap();
440        assert_eq!(quote.status, QuoteStatus::Requested);
441        // Request phase carries no pricing yet — that's the whole point.
442        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        // Empty title
453        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        // Zero amount, past validity date, non-positive duration: these
468        // validations used to live in `Quote::new()` — they moved to
469        // `submit()` along with the pricing data itself.
470        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        // A quote created without pricing cannot be bodyless-submitted —
486        // there is nothing to persist as its price.
487        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))); // 5000 * 1.21
506    }
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        // Score with min/max ranges (must use amount_incl_vat since quotes store VAT-included prices)
596        // quote1: 5000 * 1.21 = 6050, quote2: 7000 * 1.21 = 8470, quote3: 6000 * 1.21 = 7260
597        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        // Quote1: lowest price (100 * 0.4) + longest delay (0 * 0.3) + best warranty (100 * 0.2) + good reputation (80 * 0.1) = 68
608        // Quote2: highest price (0 * 0.4) + shortest delay (100 * 0.3) + low warranty (20 * 0.2) + best reputation (90 * 0.1) = 43
609        // Quote3: mid price (50 * 0.4) + mid delay (50 * 0.3) + mid warranty (50 * 0.2) + low reputation (70 * 0.1) = 52
610
611        assert!(score1.total_score > score3.total_score);
612        assert!(score3.total_score > score2.total_score);
613        assert!(score1.total_score > 60.0); // Quote1 should be best (price + warranty)
614    }
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        // Not yet submitted: no validity_date at all, so definitely not expired.
627        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        // Manually set validity_date to past (simulates time passing).
635        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    // Helper functions
660
661    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; // pricing now set via submit(), not new()
683        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}