1use crate::domain::entities::{Building, Expense, ExpenseCategory};
2use chrono::Utc;
3use printpdf::*;
4use std::collections::HashMap;
5use std::io::BufWriter;
6
7pub struct AnnualReportExporter;
11
12#[derive(Debug, Clone)]
13pub struct BudgetItem {
14 pub category: ExpenseCategory,
15 pub budgeted: f64,
16 pub actual: f64,
17}
18
19impl AnnualReportExporter {
20 pub fn export_to_pdf(
30 building: &Building,
31 year: i32,
32 expenses: &[Expense],
33 budget_items: &[BudgetItem],
34 total_income: f64,
35 reserve_fund: f64,
36 ) -> Result<Vec<u8>, String> {
37 let (doc, page1, layer1) =
39 PdfDocument::new("Rapport Financier Annuel", Mm(210.0), Mm(297.0), "Layer 1");
40 let current_layer = doc.get_page(page1).get_layer(layer1);
41
42 let font = doc
44 .add_builtin_font(BuiltinFont::Helvetica)
45 .map_err(|e| e.to_string())?;
46 let font_bold = doc
47 .add_builtin_font(BuiltinFont::HelveticaBold)
48 .map_err(|e| e.to_string())?;
49
50 let mut y = 270.0; current_layer.use_text(
54 "RAPPORT FINANCIER ANNUEL".to_string(),
55 18.0,
56 Mm(20.0),
57 Mm(y),
58 &font_bold,
59 );
60 y -= 15.0;
61
62 current_layer.use_text(
64 format!("Copropriété: {}", building.name),
65 12.0,
66 Mm(20.0),
67 Mm(y),
68 &font_bold,
69 );
70 y -= 7.0;
71
72 current_layer.use_text(
73 format!("Adresse: {}", building.address),
74 10.0,
75 Mm(20.0),
76 Mm(y),
77 &font,
78 );
79 y -= 10.0;
80
81 current_layer.use_text(
82 format!("Exercice: {}", year),
83 12.0,
84 Mm(20.0),
85 Mm(y),
86 &font_bold,
87 );
88 y -= 10.0;
89
90 current_layer.use_text(
91 format!("Date d'établissement: {}", Utc::now().format("%d/%m/%Y")),
92 10.0,
93 Mm(20.0),
94 Mm(y),
95 &font,
96 );
97 y -= 15.0;
98
99 current_layer.use_text(
101 "SYNTHÈSE FINANCIÈRE".to_string(),
102 14.0,
103 Mm(20.0),
104 Mm(y),
105 &font_bold,
106 );
107 y -= 8.0;
108
109 let total_expenses: f64 = expenses.iter().map(|e| e.amount).sum();
110
111 current_layer.use_text(
112 format!(
113 "Total des produits (charges perçues): {:.2} €",
114 total_income
115 ),
116 11.0,
117 Mm(20.0),
118 Mm(y),
119 &font,
120 );
121 y -= 6.0;
122
123 current_layer.use_text(
124 format!("Total des charges: {:.2} €", total_expenses),
125 11.0,
126 Mm(20.0),
127 Mm(y),
128 &font,
129 );
130 y -= 6.0;
131
132 let balance = total_income - total_expenses;
133 let balance_label = if balance >= 0.0 {
134 "Excédent"
135 } else {
136 "Déficit"
137 };
138 current_layer.use_text(
139 format!("{}: {:.2} €", balance_label, balance.abs()),
140 12.0,
141 Mm(20.0),
142 Mm(y),
143 &font_bold,
144 );
145 y -= 6.0;
146
147 current_layer.use_text(
148 format!("Fonds de réserve: {:.2} €", reserve_fund),
149 11.0,
150 Mm(20.0),
151 Mm(y),
152 &font,
153 );
154 y -= 12.0;
155
156 current_layer.use_text(
158 "RÉPARTITION DES CHARGES PAR CATÉGORIE".to_string(),
159 14.0,
160 Mm(20.0),
161 Mm(y),
162 &font_bold,
163 );
164 y -= 8.0;
165
166 let mut category_totals: HashMap<String, f64> = HashMap::new();
168 for expense in expenses {
169 let category_name = Self::category_name(&expense.category);
170 *category_totals.entry(category_name).or_insert(0.0) += expense.amount;
171 }
172
173 let mut sorted_categories: Vec<_> = category_totals.iter().collect();
175 sorted_categories.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap());
176
177 current_layer.use_text("Catégorie", 10.0, Mm(20.0), Mm(y), &font_bold);
179 current_layer.use_text("Montant", 10.0, Mm(120.0), Mm(y), &font_bold);
180 current_layer.use_text("% Total", 10.0, Mm(160.0), Mm(y), &font_bold);
181 y -= 6.0;
182
183 for (category, amount) in sorted_categories {
184 if y < 100.0 {
185 break;
187 }
188
189 let percentage = if total_expenses > 0.0 {
190 (amount / total_expenses) * 100.0
191 } else {
192 0.0
193 };
194
195 current_layer.use_text(category.clone(), 9.0, Mm(20.0), Mm(y), &font);
196 current_layer.use_text(format!("{:.2} €", amount), 9.0, Mm(120.0), Mm(y), &font);
197 current_layer.use_text(format!("{:.1}%", percentage), 9.0, Mm(160.0), Mm(y), &font);
198 y -= 5.0;
199 }
200 y -= 10.0;
201
202 current_layer.use_text(
204 "COMPARAISON BUDGET / RÉALISÉ".to_string(),
205 14.0,
206 Mm(20.0),
207 Mm(y),
208 &font_bold,
209 );
210 y -= 8.0;
211
212 current_layer.use_text("Catégorie", 10.0, Mm(20.0), Mm(y), &font_bold);
214 current_layer.use_text("Budget", 10.0, Mm(100.0), Mm(y), &font_bold);
215 current_layer.use_text("Réalisé", 10.0, Mm(130.0), Mm(y), &font_bold);
216 current_layer.use_text("Écart", 10.0, Mm(160.0), Mm(y), &font_bold);
217 y -= 6.0;
218
219 let mut total_budgeted = 0.0;
220 let mut total_actual = 0.0;
221
222 for item in budget_items {
223 if y < 50.0 {
224 break;
226 }
227
228 let category_name = Self::category_name(&item.category);
229 let variance = item.budgeted - item.actual;
230 let variance_sign = if variance >= 0.0 { "+" } else { "" };
231
232 current_layer.use_text(category_name, 9.0, Mm(20.0), Mm(y), &font);
233 current_layer.use_text(
234 format!("{:.2} €", item.budgeted),
235 9.0,
236 Mm(100.0),
237 Mm(y),
238 &font,
239 );
240 current_layer.use_text(
241 format!("{:.2} €", item.actual),
242 9.0,
243 Mm(130.0),
244 Mm(y),
245 &font,
246 );
247 current_layer.use_text(
248 format!("{}{:.2} €", variance_sign, variance),
249 9.0,
250 Mm(160.0),
251 Mm(y),
252 &font,
253 );
254
255 total_budgeted += item.budgeted;
256 total_actual += item.actual;
257 y -= 5.0;
258 }
259 y -= 3.0;
260
261 current_layer.use_text("TOTAL", 10.0, Mm(20.0), Mm(y), &font_bold);
263 current_layer.use_text(
264 format!("{:.2} €", total_budgeted),
265 10.0,
266 Mm(100.0),
267 Mm(y),
268 &font_bold,
269 );
270 current_layer.use_text(
271 format!("{:.2} €", total_actual),
272 10.0,
273 Mm(130.0),
274 Mm(y),
275 &font_bold,
276 );
277
278 let total_variance = total_budgeted - total_actual;
279 let total_variance_sign = if total_variance >= 0.0 { "+" } else { "" };
280 current_layer.use_text(
281 format!("{}{:.2} €", total_variance_sign, total_variance),
282 10.0,
283 Mm(160.0),
284 Mm(y),
285 &font_bold,
286 );
287 y -= 15.0;
288
289 if y < 40.0 {
291 y = 40.0;
292 }
293
294 current_layer.use_text("SIGNATURES".to_string(), 12.0, Mm(20.0), Mm(y), &font_bold);
295 y -= 10.0;
296
297 current_layer.use_text(
298 "Le Syndic: ________________".to_string(),
299 10.0,
300 Mm(20.0),
301 Mm(y),
302 &font,
303 );
304
305 current_layer.use_text(
306 "Le Trésorier: ________________".to_string(),
307 10.0,
308 Mm(120.0),
309 Mm(y),
310 &font,
311 );
312 y -= 6.0;
313
314 current_layer.use_text(
315 "Date: ________________".to_string(),
316 10.0,
317 Mm(20.0),
318 Mm(y),
319 &font,
320 );
321
322 let mut buffer = Vec::new();
324 doc.save(&mut BufWriter::new(&mut buffer))
325 .map_err(|e| e.to_string())?;
326
327 Ok(buffer)
328 }
329
330 fn category_name(category: &ExpenseCategory) -> String {
331 match category {
332 ExpenseCategory::Maintenance => "Entretien".to_string(),
333 ExpenseCategory::Utilities => "Charges courantes".to_string(),
334 ExpenseCategory::Insurance => "Assurances".to_string(),
335 ExpenseCategory::Repairs => "Réparations".to_string(),
336 ExpenseCategory::Administration => "Administration".to_string(),
337 ExpenseCategory::Cleaning => "Nettoyage".to_string(),
338 ExpenseCategory::Works => "Travaux".to_string(),
339 ExpenseCategory::Other => "Autres".to_string(),
340 }
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::domain::entities::ApprovalStatus;
348 use uuid::Uuid;
349
350 #[test]
351 fn test_export_annual_report_pdf() {
352 let building = Building {
353 id: Uuid::new_v4(),
354 name: "Les Jardins de Bruxelles".to_string(),
355 address: "123 Avenue Louise".to_string(),
356 city: "Bruxelles".to_string(),
357 postal_code: "1000".to_string(),
358 country: "Belgium".to_string(),
359 total_units: 10,
360 total_tantiemes: 1000,
361 construction_year: Some(1990),
362 syndic_name: None,
363 syndic_email: None,
364 syndic_phone: None,
365 syndic_address: None,
366 syndic_office_hours: None,
367 syndic_emergency_contact: None,
368 slug: None,
369 organization_id: Uuid::new_v4(),
370 created_at: Utc::now(),
371 updated_at: Utc::now(),
372 };
373
374 let expenses = vec![
375 Expense {
376 id: Uuid::new_v4(),
377 building_id: building.id,
378 organization_id: building.organization_id,
379 description: "Entretien ascenseur".to_string(),
380 amount: 1500.0,
381 amount_excl_vat: Some(1239.67),
382 vat_rate: Some(21.0),
383 vat_amount: Some(260.33),
384 amount_incl_vat: Some(1500.0),
385 expense_date: Utc::now(),
386 invoice_date: None,
387 due_date: None,
388 paid_date: Some(Utc::now()),
389 category: ExpenseCategory::Maintenance,
390 approval_status: ApprovalStatus::Approved,
391 submitted_at: None,
392 approved_by: None,
393 approved_at: None,
394 rejection_reason: None,
395 payment_status: crate::domain::entities::PaymentStatus::Paid,
396 supplier: None,
397 invoice_number: Some("INV-001".to_string()),
398 account_code: None,
399 created_at: Utc::now(),
400 updated_at: Utc::now(),
401 },
402 Expense {
403 id: Uuid::new_v4(),
404 building_id: building.id,
405 organization_id: building.organization_id,
406 description: "Électricité parties communes".to_string(),
407 amount: 800.0,
408 amount_excl_vat: Some(661.16),
409 vat_rate: Some(21.0),
410 vat_amount: Some(138.84),
411 amount_incl_vat: Some(800.0),
412 expense_date: Utc::now(),
413 invoice_date: None,
414 due_date: None,
415 paid_date: Some(Utc::now()),
416 category: ExpenseCategory::Utilities,
417 approval_status: ApprovalStatus::Approved,
418 submitted_at: None,
419 approved_by: None,
420 approved_at: None,
421 rejection_reason: None,
422 payment_status: crate::domain::entities::PaymentStatus::Paid,
423 supplier: None,
424 invoice_number: Some("INV-002".to_string()),
425 account_code: None,
426 created_at: Utc::now(),
427 updated_at: Utc::now(),
428 },
429 ];
430
431 let budget_items = vec![
432 BudgetItem {
433 category: ExpenseCategory::Maintenance,
434 budgeted: 2000.0,
435 actual: 1500.0,
436 },
437 BudgetItem {
438 category: ExpenseCategory::Utilities,
439 budgeted: 1000.0,
440 actual: 800.0,
441 },
442 ];
443
444 let result = AnnualReportExporter::export_to_pdf(
445 &building,
446 2025,
447 &expenses,
448 &budget_items,
449 3000.0, 5000.0, );
452
453 assert!(result.is_ok());
454 let pdf_bytes = result.unwrap();
455 assert!(!pdf_bytes.is_empty());
456 assert!(pdf_bytes.len() > 100);
457 }
458}