1use crate::domain::entities::{Building, Meeting, MeetingType, Resolution, Vote};
2use printpdf::*;
3use std::io::BufWriter;
4use uuid::Uuid;
5
6pub struct MeetingMinutesExporter;
10
11#[derive(Debug, Clone)]
12pub struct AttendeeInfo {
13 pub owner_id: Uuid,
14 pub name: String,
15 pub email: String,
16 pub voting_power: f64, pub is_proxy: bool,
18 pub proxy_for: Option<String>, }
20
21#[derive(Debug, Clone)]
22pub struct ResolutionWithVotes {
23 pub resolution: Resolution,
24 pub votes: Vec<Vote>,
25}
26
27impl MeetingMinutesExporter {
28 pub fn export_to_pdf(
38 building: &Building,
39 meeting: &Meeting,
40 attendees: &[AttendeeInfo],
41 resolutions: &[ResolutionWithVotes],
42 ) -> Result<Vec<u8>, String> {
43 let (doc, page1, layer1) = PdfDocument::new(
45 "Procès-Verbal d'Assemblée Générale",
46 Mm(210.0),
47 Mm(297.0),
48 "Layer 1",
49 );
50 let current_layer = doc.get_page(page1).get_layer(layer1);
51
52 let font = doc
54 .add_builtin_font(BuiltinFont::Helvetica)
55 .map_err(|e| e.to_string())?;
56 let font_bold = doc
57 .add_builtin_font(BuiltinFont::HelveticaBold)
58 .map_err(|e| e.to_string())?;
59
60 let mut y = 270.0; current_layer.use_text(
64 "PROCÈS-VERBAL D'ASSEMBLÉE GÉNÉRALE".to_string(),
65 18.0,
66 Mm(20.0),
67 Mm(y),
68 &font_bold,
69 );
70 y -= 15.0;
71
72 current_layer.use_text(
74 format!("Copropriété: {}", building.name),
75 12.0,
76 Mm(20.0),
77 Mm(y),
78 &font_bold,
79 );
80 y -= 7.0;
81
82 current_layer.use_text(
83 format!("Adresse: {}", building.address),
84 10.0,
85 Mm(20.0),
86 Mm(y),
87 &font,
88 );
89 y -= 10.0;
90
91 let meeting_type_label = match meeting.meeting_type {
93 MeetingType::Ordinary => "Assemblée Générale Ordinaire (AGO)",
94 MeetingType::Extraordinary => "Assemblée Générale Extraordinaire (AGE)",
95 };
96
97 current_layer.use_text(
98 format!("Type: {}", meeting_type_label),
99 10.0,
100 Mm(20.0),
101 Mm(y),
102 &font,
103 );
104 y -= 6.0;
105
106 let date_str = meeting
107 .scheduled_date
108 .format("%d/%m/%Y à %H:%M")
109 .to_string();
110 current_layer.use_text(format!("Date: {}", date_str), 10.0, Mm(20.0), Mm(y), &font);
111 y -= 6.0;
112
113 current_layer.use_text(
114 format!("Lieu: {}", meeting.location),
115 10.0,
116 Mm(20.0),
117 Mm(y),
118 &font,
119 );
120 y -= 6.0;
121 y -= 5.0;
122
123 current_layer.use_text(
125 "PRÉSENCES ET REPRÉSENTATIONS".to_string(),
126 14.0,
127 Mm(20.0),
128 Mm(y),
129 &font_bold,
130 );
131 y -= 8.0;
132
133 let total_voting_power: f64 = attendees.iter().map(|a| a.voting_power).sum();
135 let total_millimes = building.total_units as f64 * 1000.0; let quorum_percentage = (total_voting_power / total_millimes) * 100.0;
137
138 current_layer.use_text(
139 format!(
140 "Présents ou représentés: {} millièmes sur {} ({:.2}%)",
141 total_voting_power, total_millimes, quorum_percentage
142 ),
143 10.0,
144 Mm(20.0),
145 Mm(y),
146 &font,
147 );
148 y -= 10.0;
149
150 current_layer.use_text("Copropriétaire", 10.0, Mm(20.0), Mm(y), &font_bold);
152 current_layer.use_text("Millièmes", 10.0, Mm(110.0), Mm(y), &font_bold);
153 current_layer.use_text("Présence", 10.0, Mm(150.0), Mm(y), &font_bold);
154 y -= 6.0;
155
156 for attendee in attendees {
158 if y < 30.0 {
159 break;
161 }
162
163 current_layer.use_text(&attendee.name, 9.0, Mm(20.0), Mm(y), &font);
164 current_layer.use_text(
165 format!("{:.2}", attendee.voting_power),
166 9.0,
167 Mm(110.0),
168 Mm(y),
169 &font,
170 );
171
172 let presence = if attendee.is_proxy {
173 if let Some(ref proxy_for) = attendee.proxy_for {
174 format!("Mandataire pour {}", proxy_for)
175 } else {
176 "Mandataire".to_string()
177 }
178 } else {
179 "Présent".to_string()
180 };
181
182 current_layer.use_text(presence, 9.0, Mm(150.0), Mm(y), &font);
183 y -= 5.0;
184 }
185 y -= 8.0;
186
187 let quorum_status = if quorum_percentage >= 50.0 {
189 "✓ QUORUM ATTEINT"
190 } else {
191 "✗ QUORUM NON ATTEINT"
192 };
193
194 current_layer.use_text(quorum_status.to_string(), 11.0, Mm(20.0), Mm(y), &font_bold);
195 y -= 12.0;
196
197 current_layer.use_text(
199 "RÉSOLUTIONS ET VOTES".to_string(),
200 14.0,
201 Mm(20.0),
202 Mm(y),
203 &font_bold,
204 );
205 y -= 10.0;
206
207 for (idx, res_with_votes) in resolutions.iter().enumerate() {
208 if y < 50.0 {
209 break;
211 }
212
213 let resolution = &res_with_votes.resolution;
214
215 current_layer.use_text(
217 format!("Résolution n°{}: {}", idx + 1, resolution.title),
218 11.0,
219 Mm(20.0),
220 Mm(y),
221 &font_bold,
222 );
223 y -= 6.0;
224
225 let description = if resolution.description.len() > 80 {
227 format!("{}...", &resolution.description[..80])
228 } else {
229 resolution.description.clone()
230 };
231
232 current_layer.use_text(description, 9.0, Mm(25.0), Mm(y), &font);
233 y -= 6.0;
234
235 let majority_label = match &resolution.majority_required {
237 crate::domain::entities::MajorityType::Simple => "Majorité simple",
238 crate::domain::entities::MajorityType::Absolute => "Majorité absolue",
239 crate::domain::entities::MajorityType::Qualified(threshold) => {
240 &format!("Majorité qualifiée ({:.0}%)", threshold * 100.0)
241 }
242 };
243
244 current_layer.use_text(
245 format!("Majorité requise: {}", majority_label),
246 9.0,
247 Mm(25.0),
248 Mm(y),
249 &font,
250 );
251 y -= 6.0;
252
253 current_layer.use_text(
255 format!(
256 "Pour: {} votes ({:.2} millièmes) | Contre: {} votes ({:.2} millièmes) | Abstention: {} votes ({:.2} millièmes)",
257 resolution.vote_count_pour,
258 resolution.total_voting_power_pour,
259 resolution.vote_count_contre,
260 resolution.total_voting_power_contre,
261 resolution.vote_count_abstention,
262 resolution.total_voting_power_abstention
263 ),
264 9.0,
265 Mm(25.0),
266 Mm(y),
267 &font,
268 );
269 y -= 6.0;
270
271 let (result_text, result_symbol) = match &resolution.status {
273 crate::domain::entities::ResolutionStatus::Adopted => ("ADOPTÉE", "✓"),
274 crate::domain::entities::ResolutionStatus::Rejected => ("REJETÉE", "✗"),
275 crate::domain::entities::ResolutionStatus::Pending => ("EN ATTENTE", "○"),
276 };
277
278 current_layer.use_text(
279 format!("{} Résolution {}", result_symbol, result_text),
280 10.0,
281 Mm(25.0),
282 Mm(y),
283 &font_bold,
284 );
285 y -= 10.0;
286 }
287
288 if y < 40.0 {
290 y = 40.0; } else {
292 y -= 10.0;
293 }
294
295 current_layer.use_text("SIGNATURES".to_string(), 12.0, Mm(20.0), Mm(y), &font_bold);
296 y -= 10.0;
297
298 current_layer.use_text(
299 "Le Président de séance: ________________",
300 10.0,
301 Mm(20.0),
302 Mm(y),
303 &font,
304 );
305
306 current_layer.use_text(
307 "Le Secrétaire: ________________",
308 10.0,
309 Mm(120.0),
310 Mm(y),
311 &font,
312 );
313
314 let mut buffer = Vec::new();
316 doc.save(&mut BufWriter::new(&mut buffer))
317 .map_err(|e| e.to_string())?;
318
319 Ok(buffer)
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::domain::entities::{MajorityType, MeetingStatus, ResolutionStatus, ResolutionType};
327 use chrono::Utc;
328
329 #[test]
330 fn test_export_meeting_minutes_pdf() {
331 let building = Building {
332 id: Uuid::new_v4(),
333 name: "Les Jardins de Bruxelles".to_string(),
334 address: "123 Avenue Louise".to_string(),
335 city: "Bruxelles".to_string(),
336 postal_code: "1000".to_string(),
337 country: "Belgium".to_string(),
338 total_units: 10,
339 total_tantiemes: 1000,
340 construction_year: Some(1990),
341 syndic_name: None,
342 syndic_email: None,
343 syndic_phone: None,
344 syndic_address: None,
345 syndic_office_hours: None,
346 syndic_emergency_contact: None,
347 slug: None,
348 organization_id: Uuid::new_v4(),
349 created_at: Utc::now(),
350 updated_at: Utc::now(),
351 };
352
353 let meeting = Meeting {
354 id: Uuid::new_v4(),
355 organization_id: building.organization_id,
356 building_id: building.id,
357 meeting_type: MeetingType::Ordinary,
358 title: "Assemblée Générale Ordinaire".to_string(),
359 description: Some("Ordre du jour: budget et travaux".to_string()),
360 scheduled_date: Utc::now(),
361 location: "Salle communale".to_string(),
362 status: MeetingStatus::Scheduled,
363 agenda: vec![
364 "Approbation du budget".to_string(),
365 "Travaux de façade".to_string(),
366 ],
367 attendees_count: Some(2),
368 created_at: Utc::now(),
369 updated_at: Utc::now(),
370 };
371
372 let attendees = vec![
373 AttendeeInfo {
374 owner_id: Uuid::new_v4(),
375 name: "Jean Dupont".to_string(),
376 email: "jean@example.com".to_string(),
377 voting_power: 150.0,
378 is_proxy: false,
379 proxy_for: None,
380 },
381 AttendeeInfo {
382 owner_id: Uuid::new_v4(),
383 name: "Marie Martin".to_string(),
384 email: "marie@example.com".to_string(),
385 voting_power: 120.0,
386 is_proxy: true,
387 proxy_for: Some("Pierre Durant".to_string()),
388 },
389 ];
390
391 let resolution = Resolution {
392 id: Uuid::new_v4(),
393 meeting_id: meeting.id,
394 title: "Approbation du budget 2025".to_string(),
395 description: "Le budget prévisionnel pour l'exercice 2025 est approuvé.".to_string(),
396 resolution_type: ResolutionType::Ordinary,
397 majority_required: MajorityType::Simple,
398 vote_count_pour: 2,
399 vote_count_contre: 0,
400 vote_count_abstention: 0,
401 total_voting_power_pour: 270.0,
402 total_voting_power_contre: 0.0,
403 total_voting_power_abstention: 0.0,
404 status: ResolutionStatus::Adopted,
405 voted_at: Some(Utc::now()),
406 created_at: Utc::now(),
407 };
408
409 let resolutions = vec![ResolutionWithVotes {
410 resolution,
411 votes: vec![],
412 }];
413
414 let result =
415 MeetingMinutesExporter::export_to_pdf(&building, &meeting, &attendees, &resolutions);
416
417 assert!(result.is_ok());
418 let pdf_bytes = result.unwrap();
419 assert!(!pdf_bytes.is_empty());
420 assert!(pdf_bytes.len() > 100); }
422}