koprogo_api/application/dto/
quote_dto.rs

1use crate::domain::entities::{Quote, QuoteScore};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4
5/// Create new quote request DTO
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct CreateQuoteDto {
8    pub building_id: String,
9    pub contractor_id: String,
10    pub project_title: String,
11    pub project_description: String,
12    pub amount_excl_vat: Decimal,
13    pub vat_rate: Decimal,
14    pub validity_date: String, // ISO 8601 string
15    pub estimated_start_date: Option<String>,
16    pub estimated_duration_days: i32,
17    pub warranty_years: i32,
18}
19
20/// Update quote details DTO (contractor submission)
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct UpdateQuoteDto {
23    pub amount_excl_vat: Option<Decimal>,
24    pub vat_rate: Option<Decimal>,
25    pub estimated_start_date: Option<String>,
26    pub estimated_duration_days: Option<i32>,
27    pub warranty_years: Option<i32>,
28}
29
30/// Quote decision DTO (Syndic accept/reject)
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct QuoteDecisionDto {
33    pub decision_notes: Option<String>,
34}
35
36/// Quote comparison request DTO
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct QuoteComparisonRequestDto {
39    pub quote_ids: Vec<String>, // At least 3 quotes (Belgian law)
40}
41
42/// Quote response DTO
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct QuoteResponseDto {
45    pub id: String,
46    pub building_id: String,
47    pub contractor_id: String,
48    pub project_title: String,
49    pub project_description: String,
50
51    // Quote details
52    pub amount_excl_vat: String, // Decimal as string
53    pub vat_rate: String,        // Decimal as string
54    pub amount_incl_vat: String, // Decimal as string
55    pub validity_date: String,
56    pub estimated_start_date: Option<String>,
57    pub estimated_duration_days: i32,
58
59    // Scoring factors
60    pub warranty_years: i32,
61    pub contractor_rating: Option<i32>,
62
63    // Status
64    pub status: String,
65    pub is_expired: bool,
66
67    // Workflow metadata
68    pub requested_at: String,
69    pub submitted_at: Option<String>,
70    pub reviewed_at: Option<String>,
71    pub decision_at: Option<String>,
72    pub decision_by: Option<String>,
73    pub decision_notes: Option<String>,
74
75    // Audit trail
76    pub created_at: String,
77    pub updated_at: String,
78}
79
80impl From<Quote> for QuoteResponseDto {
81    fn from(quote: Quote) -> Self {
82        Self {
83            id: quote.id.to_string(),
84            building_id: quote.building_id.to_string(),
85            contractor_id: quote.contractor_id.to_string(),
86            project_title: quote.project_title.clone(),
87            project_description: quote.project_description.clone(),
88            amount_excl_vat: format!("{:.2}", quote.amount_excl_vat),
89            vat_rate: format!("{:.2}", quote.vat_rate),
90            amount_incl_vat: format!("{:.2}", quote.amount_incl_vat),
91            validity_date: quote.validity_date.to_rfc3339(),
92            estimated_start_date: quote.estimated_start_date.map(|d| d.to_rfc3339()),
93            estimated_duration_days: quote.estimated_duration_days,
94            warranty_years: quote.warranty_years,
95            contractor_rating: quote.contractor_rating,
96            status: quote.status.to_sql().to_string(),
97            is_expired: quote.is_expired(),
98            requested_at: quote.requested_at.to_rfc3339(),
99            submitted_at: quote.submitted_at.map(|d| d.to_rfc3339()),
100            reviewed_at: quote.reviewed_at.map(|d| d.to_rfc3339()),
101            decision_at: quote.decision_at.map(|d| d.to_rfc3339()),
102            decision_by: quote.decision_by.map(|u| u.to_string()),
103            decision_notes: quote.decision_notes,
104            created_at: quote.created_at.to_rfc3339(),
105            updated_at: quote.updated_at.to_rfc3339(),
106        }
107    }
108}
109
110/// Quote score response DTO
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct QuoteScoreResponseDto {
113    pub quote_id: String,
114    pub total_score: f32,
115    pub price_score: f32,
116    pub delay_score: f32,
117    pub warranty_score: f32,
118    pub reputation_score: f32,
119}
120
121impl From<QuoteScore> for QuoteScoreResponseDto {
122    fn from(score: QuoteScore) -> Self {
123        Self {
124            quote_id: score.quote_id.to_string(),
125            total_score: score.total_score,
126            price_score: score.price_score,
127            delay_score: score.delay_score,
128            warranty_score: score.warranty_score,
129            reputation_score: score.reputation_score,
130        }
131    }
132}
133
134/// Quote comparison result DTO (includes quote + score)
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct QuoteComparisonItemDto {
137    pub quote: QuoteResponseDto,
138    pub score: Option<QuoteScoreResponseDto>,
139    pub rank: usize, // 1, 2, 3, etc. (sorted by score)
140}
141
142/// Quote comparison response DTO (Belgian legal requirement: 3 quotes minimum)
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct QuoteComparisonResponseDto {
145    pub project_title: String,
146    pub building_id: String,
147    pub total_quotes: usize,
148    pub comparison_items: Vec<QuoteComparisonItemDto>,
149
150    // Aggregated statistics
151    pub min_price: String, // Decimal as string
152    pub max_price: String,
153    pub avg_price: String,
154    pub min_duration_days: i32,
155    pub max_duration_days: i32,
156    pub avg_duration_days: f32,
157
158    // Recommendation (top-ranked quote)
159    pub recommended_quote_id: Option<String>,
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::domain::entities::Quote;
166    use chrono::Utc;
167    use rust_decimal::Decimal;
168    use std::str::FromStr;
169    use uuid::Uuid;
170
171    // Helper macro since dec! is not available in rust_decimal 1.36
172    macro_rules! dec {
173        ($val:expr) => {
174            Decimal::from_str(stringify!($val)).unwrap()
175        };
176    }
177
178    #[test]
179    fn test_quote_response_dto_conversion() {
180        let building_id = Uuid::new_v4();
181        let contractor_id = Uuid::new_v4();
182        let validity_date = Utc::now() + chrono::Duration::days(30);
183
184        let quote = Quote::new(
185            building_id,
186            contractor_id,
187            "Roof Repair".to_string(),
188            "Repair leaking roof tiles".to_string(),
189            dec!(5000.00),
190            dec!(0.21),
191            validity_date,
192            14,
193            10,
194        )
195        .unwrap();
196
197        let dto = QuoteResponseDto::from(quote.clone());
198
199        assert_eq!(dto.id, quote.id.to_string());
200        assert_eq!(dto.project_title, "Roof Repair");
201        assert_eq!(dto.amount_excl_vat, "5000.00");
202        assert_eq!(dto.amount_incl_vat, "6050.00");
203        assert_eq!(dto.status, "Requested");
204        assert!(!dto.is_expired);
205        assert_eq!(dto.estimated_duration_days, 14);
206        assert_eq!(dto.warranty_years, 10);
207    }
208
209    #[test]
210    fn test_quote_score_dto_conversion() {
211        let quote_id = Uuid::new_v4();
212        let score = QuoteScore {
213            quote_id,
214            total_score: 75.5,
215            price_score: 80.0,
216            delay_score: 70.0,
217            warranty_score: 90.0,
218            reputation_score: 60.0,
219        };
220
221        let dto = QuoteScoreResponseDto::from(score.clone());
222
223        assert_eq!(dto.quote_id, quote_id.to_string());
224        assert_eq!(dto.total_score, 75.5);
225        assert_eq!(dto.price_score, 80.0);
226        assert_eq!(dto.delay_score, 70.0);
227        assert_eq!(dto.warranty_score, 90.0);
228        assert_eq!(dto.reputation_score, 60.0);
229    }
230}