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::Absolute => {
238                    "Majorité absolue (Art. 3.88 §1)"
239                }
240                crate::domain::entities::MajorityType::TwoThirds => {
241                    "Majorité des 2/3 (Art. 3.88 §1, 1°)"
242                }
243                crate::domain::entities::MajorityType::FourFifths => {
244                    "Majorité des 4/5 (Art. 3.88 §1, 2°)"
245                }
246                crate::domain::entities::MajorityType::Unanimity => "Unanimité (Art. 3.88 §1, 3°)",
247            };
248
249            current_layer.use_text(
250                format!("Majorité requise: {}", majority_label),
251                9.0,
252                Mm(25.0),
253                Mm(y),
254                &font,
255            );
256            y -= 6.0;
257
258            // Vote results
259            current_layer.use_text(
260                format!(
261                    "Pour: {} votes ({:.2} millièmes) | Contre: {} votes ({:.2} millièmes) | Abstention: {} votes ({:.2} millièmes)",
262                    resolution.vote_count_pour,
263                    resolution.total_voting_power_pour,
264                    resolution.vote_count_contre,
265                    resolution.total_voting_power_contre,
266                    resolution.vote_count_abstention,
267                    resolution.total_voting_power_abstention
268                ),
269                9.0,
270                Mm(25.0),
271                Mm(y),
272                &font,
273            );
274            y -= 6.0;
275
276            // Result
277            let (result_text, result_symbol) = match &resolution.status {
278                crate::domain::entities::ResolutionStatus::Adopted => ("ADOPTÉE", "✓"),
279                crate::domain::entities::ResolutionStatus::Rejected => ("REJETÉE", "✗"),
280                crate::domain::entities::ResolutionStatus::Pending => ("EN ATTENTE", "○"),
281            };
282
283            current_layer.use_text(
284                format!("{} Résolution {}", result_symbol, result_text),
285                10.0,
286                Mm(25.0),
287                Mm(y),
288                &font_bold,
289            );
290            y -= 10.0;
291        }
292
293        // === SIGNATURES SECTION ===
294        if y < 40.0 {
295            y = 40.0; // Force to bottom of page
296        } else {
297            y -= 10.0;
298        }
299
300        current_layer.use_text("SIGNATURES".to_string(), 12.0, Mm(20.0), Mm(y), &font_bold);
301        y -= 10.0;
302
303        current_layer.use_text(
304            "Le Président de séance: ________________",
305            10.0,
306            Mm(20.0),
307            Mm(y),
308            &font,
309        );
310
311        current_layer.use_text(
312            "Le Secrétaire: ________________",
313            10.0,
314            Mm(120.0),
315            Mm(y),
316            &font,
317        );
318
319        // Save to bytes
320        let mut buffer = Vec::new();
321        doc.save(&mut BufWriter::new(&mut buffer))
322            .map_err(|e| e.to_string())?;
323
324        Ok(buffer)
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::domain::entities::{MajorityType, MeetingStatus, ResolutionStatus, ResolutionType};
332    use chrono::Utc;
333
334    #[test]
335    fn test_export_meeting_minutes_pdf() {
336        let building = Building {
337            id: Uuid::new_v4(),
338            name: "Les Jardins de Bruxelles".to_string(),
339            address: "123 Avenue Louise".to_string(),
340            city: "Bruxelles".to_string(),
341            postal_code: "1000".to_string(),
342            country: "Belgium".to_string(),
343            total_units: 10,
344            total_tantiemes: 1000,
345            construction_year: Some(1990),
346            syndic_name: None,
347            syndic_email: None,
348            syndic_phone: None,
349            syndic_address: None,
350            syndic_office_hours: None,
351            syndic_emergency_contact: None,
352            slug: None,
353            organization_id: Uuid::new_v4(),
354            created_at: Utc::now(),
355            updated_at: Utc::now(),
356        };
357
358        let meeting = Meeting {
359            id: Uuid::new_v4(),
360            organization_id: building.organization_id,
361            building_id: building.id,
362            meeting_type: MeetingType::Ordinary,
363            title: "Assemblée Générale Ordinaire".to_string(),
364            description: Some("Ordre du jour: budget et travaux".to_string()),
365            scheduled_date: Utc::now(),
366            location: "Salle communale".to_string(),
367            status: MeetingStatus::Scheduled,
368            agenda: vec![
369                "Approbation du budget".to_string(),
370                "Travaux de façade".to_string(),
371            ],
372            attendees_count: Some(2),
373            quorum_validated: false,
374            quorum_percentage: None,
375            total_quotas: None,
376            present_quotas: None,
377            is_second_convocation: false,
378            minutes_document_id: None,
379            minutes_sent_at: None,
380            created_at: Utc::now(),
381            updated_at: Utc::now(),
382        };
383
384        let attendees = vec![
385            AttendeeInfo {
386                owner_id: Uuid::new_v4(),
387                name: "Jean Dupont".to_string(),
388                email: "jean@example.com".to_string(),
389                voting_power: 150.0,
390                is_proxy: false,
391                proxy_for: None,
392            },
393            AttendeeInfo {
394                owner_id: Uuid::new_v4(),
395                name: "Marie Martin".to_string(),
396                email: "marie@example.com".to_string(),
397                voting_power: 120.0,
398                is_proxy: true,
399                proxy_for: Some("Pierre Durant".to_string()),
400            },
401        ];
402
403        let resolution = Resolution {
404            id: Uuid::new_v4(),
405            meeting_id: meeting.id,
406            title: "Approbation du budget 2025".to_string(),
407            description: "Le budget prévisionnel pour l'exercice 2025 est approuvé.".to_string(),
408            resolution_type: ResolutionType::Ordinary,
409            majority_required: MajorityType::Absolute,
410            vote_count_pour: 2,
411            vote_count_contre: 0,
412            vote_count_abstention: 0,
413            total_voting_power_pour: 270.0,
414            total_voting_power_contre: 0.0,
415            total_voting_power_abstention: 0.0,
416            status: ResolutionStatus::Adopted,
417            agenda_item_index: None,
418            voted_at: Some(Utc::now()),
419            created_at: Utc::now(),
420        };
421
422        let resolutions = vec![ResolutionWithVotes {
423            resolution,
424            votes: vec![],
425        }];
426
427        let result =
428            MeetingMinutesExporter::export_to_pdf(&building, &meeting, &attendees, &resolutions);
429
430        assert!(result.is_ok());
431        let pdf_bytes = result.unwrap();
432        assert!(!pdf_bytes.is_empty());
433        assert!(pdf_bytes.len() > 100); // PDF should have reasonable size
434    }
435}