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
8pub 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 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 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 let doc = new_document("Devis de Travaux");
53 let mut current_layer = PdfPageBuilder::new();
54
55 let font = builtin_font(BuiltinFont::Helvetica);
57 let font_bold = builtin_font(BuiltinFont::HelveticaBold);
58
59 let mut y = 270.0; current_layer.text("DEVIS DE TRAVAUX".to_string(), 18.0, 20.0, y, &font_bold);
63 y -= 15.0;
64
65 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 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 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 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 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 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 current_layer.text("DÉTAIL DU DEVIS".to_string(), 14.0, 20.0, y, &font_bold);
145 y -= 8.0;
146
147 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 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 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); 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 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 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 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}