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