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