Skip to main content

koprogo_api/domain/services/
meeting_minutes_exporter.rs

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
11/// Meeting Minutes Exporter - Generates PDF for Procès-Verbal d'Assemblée Générale
12///
13/// Compliant with Belgian copropriété law requirements for general assembly minutes.
14pub 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, // Millièmes/tantièmes (Decimal exact — ADR-0008)
22    pub is_proxy: bool,
23    pub proxy_for: Option<String>, // Name of owner being represented
24}
25
26#[derive(Debug, Clone)]
27pub struct ResolutionWithVotes {
28    pub resolution: Resolution,
29    pub votes: Vec<Vote>,
30}
31
32impl MeetingMinutesExporter {
33    /// Export meeting minutes to PDF bytes
34    ///
35    /// Generates a complete Procès-Verbal (PV) including:
36    /// - Building information
37    /// - Meeting details (date, type, location)
38    /// - Attendees list with voting power
39    /// - Quorum validation
40    /// - Resolutions with detailed vote results
41    /// - Signatures section
42    pub fn export_to_pdf(
43        building: &Building,
44        meeting: &Meeting,
45        attendees: &[AttendeeInfo],
46        resolutions: &[ResolutionWithVotes],
47    ) -> Result<Vec<u8>, String> {
48        // Create PDF document (A4: 210mm x 297mm)
49        let doc = new_document("Procès-Verbal d'Assemblée Générale");
50        let mut current_layer = PdfPageBuilder::new();
51
52        // Load fonts
53        let font = builtin_font(BuiltinFont::Helvetica);
54        let font_bold = builtin_font(BuiltinFont::HelveticaBold);
55
56        let mut y = 270.0; // Start from top
57
58        // === HEADER ===
59        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        // Building information
69        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        // Meeting information
88        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        // === ATTENDEES SECTION ===
114        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        // Calculate total voting power (Decimal exact — ADR-0008)
124        let total_voting_power: Decimal = attendees.iter().map(|a| a.voting_power).sum();
125        let total_millimes = Decimal::from(building.total_units) * dec!(1000); // 1000 millièmes/unit
126        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        // Attendees table header
147        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        // Attendees list
153        for attendee in attendees {
154            if y < 30.0 {
155                // TODO: Add new page if needed (for now, truncate)
156                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        // Quorum validation
184        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        // === RESOLUTIONS SECTION ===
194        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                // TODO: Add new page if needed
206                break;
207            }
208
209            let resolution = &res_with_votes.resolution;
210
211            // Resolution number and title
212            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            // Description (truncate if too long)
222            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            // Majority type
232            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            // Vote results
255            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            // Result
273            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        // === SIGNATURES SECTION ===
290        if y < 40.0 {
291            y = 40.0; // Force to bottom of page
292        } 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        // Save to bytes
310        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); // PDF should have reasonable size
427    }
428}