koprogo_api/domain/services/
meeting_minutes_exporter.rs

1use crate::domain::entities::{Building, Meeting, MeetingType, Resolution, Vote};
2use printpdf::*;
3use std::io::BufWriter;
4use uuid::Uuid;
5
6/// Meeting Minutes Exporter - Generates PDF for Procès-Verbal d'Assemblée Générale
7///
8/// Compliant with Belgian copropriété law requirements for general assembly minutes.
9pub 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, // Millièmes/tantièmes
17    pub is_proxy: bool,
18    pub proxy_for: Option<String>, // Name of owner being represented
19}
20
21#[derive(Debug, Clone)]
22pub struct ResolutionWithVotes {
23    pub resolution: Resolution,
24    pub votes: Vec<Vote>,
25}
26
27impl MeetingMinutesExporter {
28    /// Export meeting minutes to PDF bytes
29    ///
30    /// Generates a complete Procès-Verbal (PV) including:
31    /// - Building information
32    /// - Meeting details (date, type, location)
33    /// - Attendees list with voting power
34    /// - Quorum validation
35    /// - Resolutions with detailed vote results
36    /// - Signatures section
37    pub fn export_to_pdf(
38        building: &Building,
39        meeting: &Meeting,
40        attendees: &[AttendeeInfo],
41        resolutions: &[ResolutionWithVotes],
42    ) -> Result<Vec<u8>, String> {
43        // Create PDF document (A4: 210mm x 297mm)
44        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        // Load fonts
53        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; // Start from top
61
62        // === HEADER ===
63        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        // Building information
73        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        // Meeting information
92        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        // === ATTENDEES SECTION ===
124        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        // Calculate total voting power
134        let total_voting_power: f64 = attendees.iter().map(|a| a.voting_power).sum();
135        let total_millimes = building.total_units as f64 * 1000.0; // Assuming 1000 millièmes per unit
136        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        // Attendees table header
151        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        // Attendees list
157        for attendee in attendees {
158            if y < 30.0 {
159                // TODO: Add new page if needed (for now, truncate)
160                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        // Quorum validation
188        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        // === RESOLUTIONS SECTION ===
198        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                // TODO: Add new page if needed
210                break;
211            }
212
213            let resolution = &res_with_votes.resolution;
214
215            // Resolution number and title
216            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            // Description (truncate if too long)
226            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            // Majority type
236            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            // Vote results
254            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            // Result
272            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        // === SIGNATURES SECTION ===
289        if y < 40.0 {
290            y = 40.0; // Force to bottom of page
291        } 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        // Save to bytes
315        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); // PDF should have reasonable size
421    }
422}