Skip to main content

koprogo_api/application/dto/
quote_dto.rs

1use crate::domain::entities::{Quote, QuoteScore, QuoteSubmission};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4
5fn default_warranty_years() -> i32 {
6    2
7}
8
9/// Create new quote request DTO ("Demander un devis" — request phase only).
10///
11/// Price fields are optional: the normal path (QuoteList.svelte) never sends
12/// them — nobody knows the price yet at request time, it arrives later via
13/// `SubmitQuoteDto` (`POST /quotes/{id}/submit`). They're kept here only as
14/// a backward-compatible escape hatch for callers that already know the
15/// price when requesting (e.g. a syndic manually recording a quote received
16/// by phone/email/paper) — units match the domain/DB convention (euros,
17/// VAT as a fraction, e.g. 0.21 for 21%), NOT the cents/percentage
18/// convention used by `SubmitQuoteDto` below.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct CreateQuoteDto {
21    pub building_id: String,
22    pub contractor_id: String,
23    pub project_title: String,
24    pub project_description: String,
25    #[serde(default)]
26    pub work_category: Option<String>,
27    #[serde(default)]
28    pub amount_excl_vat: Option<Decimal>,
29    #[serde(default)]
30    pub vat_rate: Option<Decimal>,
31    #[serde(default)]
32    pub validity_date: Option<String>, // ISO 8601 string
33    pub estimated_start_date: Option<String>,
34    #[serde(default)]
35    pub estimated_duration_days: Option<i32>,
36    #[serde(default = "default_warranty_years")]
37    pub warranty_years: i32,
38}
39
40/// Submit quote pricing DTO (contractor's actual quote — `POST /quotes/{id}/submit`).
41///
42/// Units match the frontend UI directly (`QuoteDetail.svelte`): amount in
43/// cents (`ADR-0007` boundary-conversion pattern) and VAT as a percentage
44/// (21, not 0.21) — converted to the domain's euros/fraction convention at
45/// the use-case boundary.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SubmitQuoteDto {
48    pub amount_excl_vat_cents: i64,
49    pub vat_rate: Decimal, // percentage, e.g. 21.00
50    pub validity_date: String,
51    pub estimated_duration_days: i32,
52    pub warranty_years: i32,
53}
54
55impl SubmitQuoteDto {
56    pub fn into_domain(self) -> Result<QuoteSubmission, String> {
57        let validity_date = chrono::DateTime::parse_from_rfc3339(&self.validity_date)
58            .map_err(|_| "Invalid validity_date format".to_string())?
59            .with_timezone(&chrono::Utc);
60
61        Ok(QuoteSubmission {
62            amount_excl_vat: Decimal::from(self.amount_excl_vat_cents) / Decimal::from(100),
63            vat_rate: self.vat_rate / Decimal::from(100),
64            validity_date,
65            estimated_duration_days: self.estimated_duration_days,
66            warranty_years: self.warranty_years,
67        })
68    }
69}
70
71/// Quote decision DTO (Syndic accept/reject)
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct QuoteDecisionDto {
74    pub decision_notes: Option<String>,
75}
76
77/// Quote comparison request DTO
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct QuoteComparisonRequestDto {
80    pub quote_ids: Vec<String>, // At least 3 quotes (Belgian law)
81}
82
83/// Quote response DTO.
84///
85/// Amounts are in cents and VAT as a percentage (matches `SubmitQuoteDto`'s
86/// wire units, and the frontend `Quote` interface directly) — all `None`
87/// until the quote has been submitted with pricing.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct QuoteResponseDto {
90    pub id: String,
91    pub building_id: String,
92    pub contractor_id: String,
93    pub project_title: String,
94    pub project_description: String,
95    pub work_category: Option<String>,
96
97    // Quote details — set once the quote has been submitted (Received+).
98    pub amount_excl_vat_cents: Option<i64>,
99    pub vat_rate: Option<Decimal>, // percentage, e.g. 21.00
100    pub amount_incl_vat_cents: Option<i64>,
101    pub validity_date: Option<String>,
102    pub estimated_start_date: Option<String>,
103    pub estimated_duration_days: Option<i32>,
104
105    // Scoring factors
106    pub warranty_years: i32,
107    pub contractor_rating: Option<i32>,
108
109    // Status
110    pub status: String,
111    pub is_expired: bool,
112
113    // Workflow metadata
114    pub requested_at: String,
115    pub submitted_at: Option<String>,
116    pub reviewed_at: Option<String>,
117    pub decision_at: Option<String>,
118    pub decision_by: Option<String>,
119    pub decision_notes: Option<String>,
120
121    // Audit trail
122    pub created_at: String,
123    pub updated_at: String,
124}
125
126/// Decimal euros -> integer cents, rounding to the nearest cent.
127fn to_cents(amount: Decimal) -> i64 {
128    use rust_decimal::prelude::ToPrimitive;
129    (amount * Decimal::from(100)).round().to_i64().unwrap_or(0)
130}
131
132impl From<Quote> for QuoteResponseDto {
133    fn from(quote: Quote) -> Self {
134        let is_expired = quote.is_expired();
135        Self {
136            id: quote.id.to_string(),
137            building_id: quote.building_id.to_string(),
138            contractor_id: quote.contractor_id.to_string(),
139            project_title: quote.project_title.clone(),
140            project_description: quote.project_description.clone(),
141            work_category: quote.work_category.clone(),
142            amount_excl_vat_cents: quote.amount_excl_vat.map(to_cents),
143            vat_rate: quote.vat_rate.map(|r| r * Decimal::from(100)),
144            amount_incl_vat_cents: quote.amount_incl_vat.map(to_cents),
145            validity_date: quote.validity_date.map(|d| d.to_rfc3339()),
146            estimated_start_date: quote.estimated_start_date.map(|d| d.to_rfc3339()),
147            estimated_duration_days: quote.estimated_duration_days,
148            warranty_years: quote.warranty_years,
149            contractor_rating: quote.contractor_rating,
150            status: quote.status.to_sql().to_string(),
151            is_expired,
152            requested_at: quote.requested_at.to_rfc3339(),
153            submitted_at: quote.submitted_at.map(|d| d.to_rfc3339()),
154            reviewed_at: quote.reviewed_at.map(|d| d.to_rfc3339()),
155            decision_at: quote.decision_at.map(|d| d.to_rfc3339()),
156            decision_by: quote.decision_by.map(|u| u.to_string()),
157            decision_notes: quote.decision_notes,
158            created_at: quote.created_at.to_rfc3339(),
159            updated_at: quote.updated_at.to_rfc3339(),
160        }
161    }
162}
163
164/// Quote score response DTO
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct QuoteScoreResponseDto {
167    pub quote_id: String,
168    pub total_score: f32,
169    pub price_score: f32,
170    pub delay_score: f32,
171    pub warranty_score: f32,
172    pub reputation_score: f32,
173}
174
175impl From<QuoteScore> for QuoteScoreResponseDto {
176    fn from(score: QuoteScore) -> Self {
177        Self {
178            quote_id: score.quote_id.to_string(),
179            total_score: score.total_score,
180            price_score: score.price_score,
181            delay_score: score.delay_score,
182            warranty_score: score.warranty_score,
183            reputation_score: score.reputation_score,
184        }
185    }
186}
187
188/// Quote comparison result DTO (includes quote + score)
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct QuoteComparisonItemDto {
191    pub quote: QuoteResponseDto,
192    pub score: Option<QuoteScoreResponseDto>,
193    pub rank: usize, // 1, 2, 3, etc. (sorted by score)
194}
195
196/// Quote comparison response DTO (Belgian professional best practice: 3 quotes minimum)
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct QuoteComparisonResponseDto {
199    pub project_title: String,
200    pub building_id: String,
201    pub total_quotes: usize,
202    pub comparison_items: Vec<QuoteComparisonItemDto>,
203
204    // Aggregated statistics
205    pub min_price: String, // Decimal as string
206    pub max_price: String,
207    pub avg_price: String,
208    pub min_duration_days: i32,
209    pub max_duration_days: i32,
210    pub avg_duration_days: f32,
211
212    // Recommendation (top-ranked quote)
213    pub recommended_quote_id: Option<String>,
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::domain::entities::Quote;
220    use chrono::Utc;
221    use rust_decimal::Decimal;
222    use std::str::FromStr;
223    use uuid::Uuid;
224
225    // Helper macro since dec! is not available in rust_decimal 1.36
226    macro_rules! dec {
227        ($val:expr) => {
228            Decimal::from_str(stringify!($val)).unwrap()
229        };
230    }
231
232    #[test]
233    fn test_quote_response_dto_conversion_unpriced() {
234        let building_id = Uuid::new_v4();
235        let contractor_id = Uuid::new_v4();
236
237        let quote = Quote::new(
238            building_id,
239            contractor_id,
240            "Roof Repair".to_string(),
241            "Repair leaking roof tiles".to_string(),
242            Some("roofing".to_string()),
243            10,
244        )
245        .unwrap();
246
247        let dto = QuoteResponseDto::from(quote.clone());
248
249        assert_eq!(dto.id, quote.id.to_string());
250        assert_eq!(dto.project_title, "Roof Repair");
251        assert_eq!(dto.amount_excl_vat_cents, None);
252        assert_eq!(dto.amount_incl_vat_cents, None);
253        assert_eq!(dto.status, "Requested");
254        assert!(!dto.is_expired);
255        assert_eq!(dto.warranty_years, 10);
256    }
257
258    #[test]
259    fn test_quote_response_dto_conversion_priced() {
260        let building_id = Uuid::new_v4();
261        let contractor_id = Uuid::new_v4();
262        let validity_date = Utc::now() + chrono::Duration::days(30);
263
264        let mut quote = Quote::new(
265            building_id,
266            contractor_id,
267            "Roof Repair".to_string(),
268            "Repair leaking roof tiles".to_string(),
269            Some("roofing".to_string()),
270            10,
271        )
272        .unwrap();
273        quote
274            .submit(Some(QuoteSubmission {
275                amount_excl_vat: dec!(5000.00),
276                vat_rate: dec!(0.21),
277                validity_date,
278                estimated_duration_days: 14,
279                warranty_years: 10,
280            }))
281            .unwrap();
282
283        let dto = QuoteResponseDto::from(quote.clone());
284
285        assert_eq!(dto.amount_excl_vat_cents, Some(500_000));
286        assert_eq!(dto.amount_incl_vat_cents, Some(605_000));
287        assert_eq!(dto.vat_rate, Some(dec!(21.00)));
288        assert_eq!(dto.status, "Received");
289        assert_eq!(dto.estimated_duration_days, Some(14));
290        assert_eq!(dto.warranty_years, 10);
291    }
292
293    #[test]
294    fn test_quote_score_dto_conversion() {
295        let quote_id = Uuid::new_v4();
296        let score = QuoteScore {
297            quote_id,
298            total_score: 75.5,
299            price_score: 80.0,
300            delay_score: 70.0,
301            warranty_score: 90.0,
302            reputation_score: 60.0,
303        };
304
305        let dto = QuoteScoreResponseDto::from(score.clone());
306
307        assert_eq!(dto.quote_id, quote_id.to_string());
308        assert_eq!(dto.total_score, 75.5);
309        assert_eq!(dto.price_score, 80.0);
310        assert_eq!(dto.delay_score, 70.0);
311        assert_eq!(dto.warranty_score, 90.0);
312        assert_eq!(dto.reputation_score, 60.0);
313    }
314}