Skip to main content

koprogo_api/domain/services/
work_quote_exporter.rs

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