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