koprogo_api/domain/services/
work_quote_exporter.rs

1use crate::domain::entities::{Building, Expense, ExpenseCategory};
2use printpdf::*;
3use std::io::BufWriter;
4
5/// Work Quote Document Exporter - Generates PDF for Devis de Travaux
6///
7/// Generates detailed work quotes for building maintenance and renovations.
8pub struct WorkQuoteExporter;
9
10#[derive(Debug, Clone)]
11pub struct QuoteLineItem {
12    pub description: String,
13    pub quantity: f64,
14    pub unit_price: f64,
15    pub total: f64,
16}
17
18impl WorkQuoteExporter {
19    /// Export work quote to PDF bytes
20    ///
21    /// Generates a Devis de Travaux including:
22    /// - Building information
23    /// - Work description
24    /// - Cost breakdown
25    /// - Timeline
26    /// - Approval status
27    /// - Signatures section
28    pub fn export_to_pdf(
29        building: &Building,
30        expense: &Expense,
31        line_items: &[QuoteLineItem],
32        contractor_name: &str,
33        contractor_contact: &str,
34        timeline: &str,
35    ) -> Result<Vec<u8>, String> {
36        // Validate that expense is a work-related category
37        if !matches!(
38            expense.category,
39            ExpenseCategory::Maintenance | ExpenseCategory::Repairs | ExpenseCategory::Insurance
40        ) {
41            return Err(
42                "Expense must be work-related category (Maintenance/Repairs/Insurance)".to_string(),
43            );
44        }
45
46        // Create PDF document (A4: 210mm x 297mm)
47        let (doc, page1, layer1) =
48            PdfDocument::new("Devis de Travaux", Mm(210.0), Mm(297.0), "Layer 1");
49        let current_layer = doc.get_page(page1).get_layer(layer1);
50
51        // Load fonts
52        let font = doc
53            .add_builtin_font(BuiltinFont::Helvetica)
54            .map_err(|e| e.to_string())?;
55        let font_bold = doc
56            .add_builtin_font(BuiltinFont::HelveticaBold)
57            .map_err(|e| e.to_string())?;
58
59        let mut y = 270.0; // Start from top
60
61        // === HEADER ===
62        current_layer.use_text(
63            "DEVIS DE TRAVAUX".to_string(),
64            18.0,
65            Mm(20.0),
66            Mm(y),
67            &font_bold,
68        );
69        y -= 15.0;
70
71        // Quote information
72        if let Some(ref invoice_num) = expense.invoice_number {
73            current_layer.use_text(
74                format!("Devis N°: {}", invoice_num),
75                11.0,
76                Mm(20.0),
77                Mm(y),
78                &font_bold,
79            );
80            y -= 7.0;
81        }
82
83        current_layer.use_text(
84            format!("Date: {}", expense.expense_date.format("%d/%m/%Y")),
85            10.0,
86            Mm(20.0),
87            Mm(y),
88            &font,
89        );
90        y -= 10.0;
91
92        // Building information
93        current_layer.use_text("COPROPRIÉTÉ".to_string(), 14.0, Mm(20.0), Mm(y), &font_bold);
94        y -= 8.0;
95
96        current_layer.use_text(building.name.clone(), 11.0, Mm(20.0), Mm(y), &font);
97        y -= 6.0;
98
99        current_layer.use_text(
100            format!(
101                "{}, {} {}",
102                building.address, building.postal_code, building.city
103            ),
104            10.0,
105            Mm(20.0),
106            Mm(y),
107            &font,
108        );
109        y -= 10.0;
110
111        // Contractor information
112        current_layer.use_text("PRESTATAIRE".to_string(), 14.0, Mm(20.0), Mm(y), &font_bold);
113        y -= 8.0;
114
115        current_layer.use_text(contractor_name.to_string(), 11.0, Mm(20.0), Mm(y), &font);
116        y -= 6.0;
117
118        current_layer.use_text(contractor_contact.to_string(), 10.0, Mm(20.0), Mm(y), &font);
119        y -= 10.0;
120
121        // Work description
122        current_layer.use_text(
123            "DESCRIPTION DES TRAVAUX".to_string(),
124            14.0,
125            Mm(20.0),
126            Mm(y),
127            &font_bold,
128        );
129        y -= 8.0;
130
131        // Wrap long description
132        let description_lines = Self::wrap_text(&expense.description, 80);
133        for line in description_lines {
134            current_layer.use_text(line, 10.0, Mm(20.0), Mm(y), &font);
135            y -= 6.0;
136        }
137        y -= 5.0;
138
139        // Timeline
140        current_layer.use_text(
141            format!("Délai d'exécution: {}", timeline),
142            10.0,
143            Mm(20.0),
144            Mm(y),
145            &font_bold,
146        );
147        y -= 10.0;
148
149        // === LINE ITEMS ===
150        current_layer.use_text(
151            "DÉTAIL DU DEVIS".to_string(),
152            14.0,
153            Mm(20.0),
154            Mm(y),
155            &font_bold,
156        );
157        y -= 8.0;
158
159        // Table header
160        current_layer.use_text("Description", 10.0, Mm(20.0), Mm(y), &font_bold);
161        current_layer.use_text("Quantité", 10.0, Mm(110.0), Mm(y), &font_bold);
162        current_layer.use_text("Prix Unit.", 10.0, Mm(140.0), Mm(y), &font_bold);
163        current_layer.use_text("Total", 10.0, Mm(170.0), Mm(y), &font_bold);
164        y -= 6.0;
165
166        let mut subtotal = 0.0;
167
168        for item in line_items {
169            if y < 80.0 {
170                // Reserve space for totals and signatures
171                break;
172            }
173
174            let desc = if item.description.len() > 40 {
175                format!("{}...", &item.description[..40])
176            } else {
177                item.description.clone()
178            };
179            current_layer.use_text(desc, 9.0, Mm(20.0), Mm(y), &font);
180
181            current_layer.use_text(
182                format!("{:.2}", item.quantity),
183                9.0,
184                Mm(110.0),
185                Mm(y),
186                &font,
187            );
188
189            current_layer.use_text(
190                format!("{:.2} €", item.unit_price),
191                9.0,
192                Mm(140.0),
193                Mm(y),
194                &font,
195            );
196
197            current_layer.use_text(format!("{:.2} €", item.total), 9.0, Mm(170.0), Mm(y), &font);
198
199            subtotal += item.total;
200            y -= 5.0;
201        }
202        y -= 8.0;
203
204        // === TOTALS ===
205        current_layer.use_text(
206            format!("SOUS-TOTAL: {:.2} €", subtotal),
207            11.0,
208            Mm(140.0),
209            Mm(y),
210            &font,
211        );
212        y -= 6.0;
213
214        let tva = subtotal * 0.21; // Belgian VAT 21% for work
215        current_layer.use_text(
216            format!("TVA (21%): {:.2} €", tva),
217            11.0,
218            Mm(140.0),
219            Mm(y),
220            &font,
221        );
222        y -= 6.0;
223
224        let total = subtotal + tva;
225        current_layer.use_text(
226            format!("TOTAL TTC: {:.2} €", total),
227            12.0,
228            Mm(140.0),
229            Mm(y),
230            &font_bold,
231        );
232        y -= 10.0;
233
234        // Approval status
235        let approval_text = match expense.approval_status {
236            crate::domain::entities::ApprovalStatus::Approved => "✓ Devis APPROUVÉ",
237            crate::domain::entities::ApprovalStatus::Rejected => "✗ Devis REJETÉ",
238            crate::domain::entities::ApprovalStatus::PendingApproval => {
239                "○ En attente d'approbation"
240            }
241            crate::domain::entities::ApprovalStatus::Draft => "○ Brouillon",
242        };
243
244        current_layer.use_text(approval_text.to_string(), 11.0, Mm(20.0), Mm(y), &font_bold);
245        y -= 15.0;
246
247        // === SIGNATURES ===
248        if y < 40.0 {
249            y = 40.0;
250        }
251
252        current_layer.use_text("SIGNATURES".to_string(), 12.0, Mm(20.0), Mm(y), &font_bold);
253        y -= 10.0;
254
255        current_layer.use_text(
256            "Le Syndic: ________________".to_string(),
257            10.0,
258            Mm(20.0),
259            Mm(y),
260            &font,
261        );
262
263        current_layer.use_text(
264            "Le Prestataire: ________________".to_string(),
265            10.0,
266            Mm(120.0),
267            Mm(y),
268            &font,
269        );
270        y -= 6.0;
271
272        current_layer.use_text(
273            "Date: ________________".to_string(),
274            10.0,
275            Mm(20.0),
276            Mm(y),
277            &font,
278        );
279
280        // Save to bytes
281        let mut buffer = Vec::new();
282        doc.save(&mut BufWriter::new(&mut buffer))
283            .map_err(|e| e.to_string())?;
284
285        Ok(buffer)
286    }
287
288    fn wrap_text(text: &str, max_len: usize) -> Vec<String> {
289        let mut lines = Vec::new();
290        let words: Vec<&str> = text.split_whitespace().collect();
291        let mut current_line = String::new();
292
293        for word in words {
294            if current_line.len() + word.len() + 1 > max_len {
295                if !current_line.is_empty() {
296                    lines.push(current_line.clone());
297                    current_line.clear();
298                }
299            }
300            if !current_line.is_empty() {
301                current_line.push(' ');
302            }
303            current_line.push_str(word);
304        }
305
306        if !current_line.is_empty() {
307            lines.push(current_line);
308        }
309
310        lines
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::domain::entities::ApprovalStatus;
318    use chrono::Utc;
319    use uuid::Uuid;
320
321    #[test]
322    fn test_export_work_quote_pdf() {
323        let building = Building {
324            id: Uuid::new_v4(),
325            name: "Les Jardins de Bruxelles".to_string(),
326            address: "123 Avenue Louise".to_string(),
327            city: "Bruxelles".to_string(),
328            postal_code: "1000".to_string(),
329            country: "Belgium".to_string(),
330            total_units: 10,
331            total_tantiemes: 1000,
332            construction_year: Some(1990),
333            syndic_name: None,
334            syndic_email: None,
335            syndic_phone: None,
336            syndic_address: None,
337            syndic_office_hours: None,
338            syndic_emergency_contact: None,
339            slug: None,
340            organization_id: Uuid::new_v4(),
341            created_at: Utc::now(),
342            updated_at: Utc::now(),
343        };
344
345        let expense = Expense {
346            id: Uuid::new_v4(),
347            building_id: building.id,
348            organization_id: building.organization_id,
349            description: "Rénovation de la façade principale".to_string(),
350            amount: 15000.0,
351            amount_excl_vat: Some(12396.69),
352            vat_rate: Some(21.0),
353            vat_amount: Some(2603.31),
354            amount_incl_vat: Some(15000.0),
355            expense_date: Utc::now(),
356            invoice_date: None,
357            due_date: None,
358            paid_date: None,
359            category: ExpenseCategory::Maintenance,
360            approval_status: ApprovalStatus::PendingApproval,
361            submitted_at: None,
362            approved_by: None,
363            approved_at: None,
364            rejection_reason: None,
365            payment_status: crate::domain::entities::PaymentStatus::Pending,
366            supplier: None,
367            invoice_number: Some("DEV-2025-001".to_string()),
368            account_code: None,
369            created_at: Utc::now(),
370            updated_at: Utc::now(),
371        };
372
373        let line_items = vec![
374            QuoteLineItem {
375                description: "Nettoyage haute pression".to_string(),
376                quantity: 100.0,
377                unit_price: 15.0,
378                total: 1500.0,
379            },
380            QuoteLineItem {
381                description: "Réparation briques endommagées".to_string(),
382                quantity: 50.0,
383                unit_price: 25.0,
384                total: 1250.0,
385            },
386            QuoteLineItem {
387                description: "Peinture façade".to_string(),
388                quantity: 100.0,
389                unit_price: 20.0,
390                total: 2000.0,
391            },
392        ];
393
394        let result = WorkQuoteExporter::export_to_pdf(
395            &building,
396            &expense,
397            &line_items,
398            "BatiPro SPRL",
399            "contact@batipro.be | +32 2 555 66 77",
400            "4 semaines",
401        );
402
403        assert!(result.is_ok());
404        let pdf_bytes = result.unwrap();
405        assert!(!pdf_bytes.is_empty());
406        assert!(pdf_bytes.len() > 100);
407    }
408}