Skip to main content

koprogo_api/domain/services/
work_quote_exporter.rs

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