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