1use crate::domain::entities::{Building, Expense, ExpenseCategory};
2use printpdf::*;
3use std::io::BufWriter;
4
5pub 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 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 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 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 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; 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 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 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 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 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 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 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 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 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 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 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; 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 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 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 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}