koprogo_api/domain/services/
pcn_exporter.rs

1use crate::domain::services::PcnReportLine;
2use printpdf::*;
3use std::io::BufWriter;
4
5/// PCN Exporter - Generates PDF and Excel reports
6pub struct PcnExporter;
7
8impl PcnExporter {
9    /// Export PCN report to PDF bytes
10    /// Returns PDF document as `Vec<u8>`
11    pub fn export_to_pdf(
12        building_name: &str,
13        report_lines: &[PcnReportLine],
14        total_amount: f64,
15    ) -> Result<Vec<u8>, String> {
16        // Create PDF document
17        let (doc, page1, layer1) = PdfDocument::new("Rapport PCN", Mm(210.0), Mm(297.0), "Layer 1");
18        let current_layer = doc.get_page(page1).get_layer(layer1);
19
20        // Load built-in font
21        let font = doc
22            .add_builtin_font(BuiltinFont::Helvetica)
23            .map_err(|e| e.to_string())?;
24        let font_bold = doc
25            .add_builtin_font(BuiltinFont::HelveticaBold)
26            .map_err(|e| e.to_string())?;
27
28        // Title
29        current_layer.use_text(
30            "Rapport PCN - Plan Comptable Normalisé".to_string(),
31            24.0,
32            Mm(20.0),
33            Mm(270.0),
34            &font_bold,
35        );
36
37        // Building name
38        current_layer.use_text(
39            format!("Immeuble: {}", building_name),
40            14.0,
41            Mm(20.0),
42            Mm(260.0),
43            &font,
44        );
45
46        // Table header
47        let mut y = 245.0;
48        current_layer.use_text("Code", 12.0, Mm(20.0), Mm(y), &font_bold);
49        current_layer.use_text("Libellé", 12.0, Mm(50.0), Mm(y), &font_bold);
50        current_layer.use_text("Montant (€)", 12.0, Mm(140.0), Mm(y), &font_bold);
51        current_layer.use_text("Nb", 12.0, Mm(180.0), Mm(y), &font_bold);
52
53        // Table rows
54        y -= 10.0;
55        for line in report_lines {
56            current_layer.use_text(&line.account.code, 10.0, Mm(20.0), Mm(y), &font);
57            current_layer.use_text(&line.account.label_fr, 10.0, Mm(50.0), Mm(y), &font);
58            current_layer.use_text(
59                format!("{:.2}", line.total_amount),
60                10.0,
61                Mm(140.0),
62                Mm(y),
63                &font,
64            );
65            current_layer.use_text(
66                format!("{}", line.entry_count),
67                10.0,
68                Mm(180.0),
69                Mm(y),
70                &font,
71            );
72            y -= 7.0;
73        }
74
75        // Total
76        y -= 5.0;
77        current_layer.use_text("TOTAL:", 12.0, Mm(50.0), Mm(y), &font_bold);
78        current_layer.use_text(
79            format!("{:.2} €", total_amount),
80            12.0,
81            Mm(140.0),
82            Mm(y),
83            &font_bold,
84        );
85
86        // Save to bytes
87        let mut buffer = Vec::new();
88        doc.save(&mut BufWriter::new(&mut buffer))
89            .map_err(|e| e.to_string())?;
90
91        Ok(buffer)
92    }
93
94    /// Export PCN report to Excel bytes
95    /// Returns Excel workbook as `Vec<u8>`
96    pub fn export_to_excel(
97        building_name: &str,
98        report_lines: &[PcnReportLine],
99        total_amount: f64,
100    ) -> Result<Vec<u8>, String> {
101        use rust_xlsxwriter::*;
102
103        // Create workbook
104        let mut workbook = Workbook::new();
105        let worksheet = workbook.add_worksheet();
106
107        // Set column widths
108        worksheet
109            .set_column_width(0, 10)
110            .map_err(|e| e.to_string())?; // Code
111        worksheet
112            .set_column_width(1, 35)
113            .map_err(|e| e.to_string())?; // Label NL
114        worksheet
115            .set_column_width(2, 35)
116            .map_err(|e| e.to_string())?; // Label FR
117        worksheet
118            .set_column_width(3, 35)
119            .map_err(|e| e.to_string())?; // Label DE
120        worksheet
121            .set_column_width(4, 35)
122            .map_err(|e| e.to_string())?; // Label EN
123        worksheet
124            .set_column_width(5, 15)
125            .map_err(|e| e.to_string())?; // Montant
126        worksheet
127            .set_column_width(6, 10)
128            .map_err(|e| e.to_string())?; // Nb
129
130        // Create formats
131        let bold_format = Format::new().set_bold();
132        let currency_format = Format::new().set_num_format("#,##0.00 €");
133        let header_format = Format::new()
134            .set_bold()
135            .set_background_color(Color::RGB(0xD3D3D3));
136
137        // Title
138        worksheet
139            .write_string_with_format(0, 0, "Rapport PCN - Plan Comptable Normalisé", &bold_format)
140            .map_err(|e| e.to_string())?;
141
142        // Building name
143        worksheet
144            .write_string_with_format(
145                1,
146                0,
147                format!("Immeuble: {}", building_name).as_str(),
148                &Format::new(),
149            )
150            .map_err(|e| e.to_string())?;
151
152        // Table header (row 3)
153        worksheet
154            .write_string_with_format(3, 0, "Code PCN", &header_format)
155            .map_err(|e| e.to_string())?;
156        worksheet
157            .write_string_with_format(3, 1, "Nederlands (NL)", &header_format)
158            .map_err(|e| e.to_string())?;
159        worksheet
160            .write_string_with_format(3, 2, "Français (FR)", &header_format)
161            .map_err(|e| e.to_string())?;
162        worksheet
163            .write_string_with_format(3, 3, "Deutsch (DE)", &header_format)
164            .map_err(|e| e.to_string())?;
165        worksheet
166            .write_string_with_format(3, 4, "English (EN)", &header_format)
167            .map_err(|e| e.to_string())?;
168        worksheet
169            .write_string_with_format(3, 5, "Montant", &header_format)
170            .map_err(|e| e.to_string())?;
171        worksheet
172            .write_string_with_format(3, 6, "Nb Écritures", &header_format)
173            .map_err(|e| e.to_string())?;
174
175        // Data rows
176        let mut row = 4;
177        for line in report_lines {
178            worksheet
179                .write_string(row, 0, &line.account.code)
180                .map_err(|e| e.to_string())?;
181            worksheet
182                .write_string(row, 1, &line.account.label_nl)
183                .map_err(|e| e.to_string())?;
184            worksheet
185                .write_string(row, 2, &line.account.label_fr)
186                .map_err(|e| e.to_string())?;
187            worksheet
188                .write_string(row, 3, &line.account.label_de)
189                .map_err(|e| e.to_string())?;
190            worksheet
191                .write_string(row, 4, &line.account.label_en)
192                .map_err(|e| e.to_string())?;
193            worksheet
194                .write_number_with_format(row, 5, line.total_amount, &currency_format)
195                .map_err(|e| e.to_string())?;
196            worksheet
197                .write_number(row, 6, line.entry_count as f64)
198                .map_err(|e| e.to_string())?;
199            row += 1;
200        }
201
202        // Total row
203        row += 1;
204        worksheet
205            .write_string_with_format(row, 4, "TOTAL:", &bold_format)
206            .map_err(|e| e.to_string())?;
207        worksheet
208            .write_number_with_format(
209                row,
210                5,
211                total_amount,
212                &Format::new().set_bold().set_num_format("#,##0.00 €"),
213            )
214            .map_err(|e| e.to_string())?;
215
216        // Save to bytes
217        let buffer = workbook.save_to_buffer().map_err(|e| e.to_string())?;
218
219        Ok(buffer)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::domain::entities::ExpenseCategory;
227    use crate::domain::services::PcnMapper;
228
229    fn create_test_report() -> (Vec<PcnReportLine>, f64) {
230        let lines = vec![
231            PcnReportLine {
232                account: PcnMapper::map_expense_to_pcn(&ExpenseCategory::Maintenance),
233                total_amount: 1500.0,
234                entry_count: 5,
235            },
236            PcnReportLine {
237                account: PcnMapper::map_expense_to_pcn(&ExpenseCategory::Utilities),
238                total_amount: 800.0,
239                entry_count: 3,
240            },
241            PcnReportLine {
242                account: PcnMapper::map_expense_to_pcn(&ExpenseCategory::Insurance),
243                total_amount: 2000.0,
244                entry_count: 1,
245            },
246        ];
247        let total = lines.iter().map(|l| l.total_amount).sum();
248        (lines, total)
249    }
250
251    // ===== PDF Export Tests =====
252
253    #[test]
254    fn test_export_pdf_returns_bytes() {
255        let (lines, total) = create_test_report();
256
257        let result = PcnExporter::export_to_pdf("Test Building", &lines, total);
258
259        assert!(result.is_ok());
260        let pdf_bytes = result.unwrap();
261        assert!(!pdf_bytes.is_empty());
262
263        // PDF should start with PDF magic bytes
264        assert_eq!(&pdf_bytes[0..4], b"%PDF");
265    }
266
267    #[test]
268    fn test_export_pdf_empty_report() {
269        let result = PcnExporter::export_to_pdf("Empty Building", &[], 0.0);
270
271        assert!(result.is_ok());
272        let pdf_bytes = result.unwrap();
273        assert!(!pdf_bytes.is_empty());
274        assert_eq!(&pdf_bytes[0..4], b"%PDF");
275    }
276
277    #[test]
278    fn test_export_pdf_contains_building_name() {
279        let (lines, total) = create_test_report();
280
281        let result = PcnExporter::export_to_pdf("My Test Building", &lines, total);
282
283        assert!(result.is_ok());
284        // We can't easily check PDF content in unit tests, but we verify it doesn't error
285    }
286
287    // ===== Excel Export Tests =====
288
289    #[test]
290    fn test_export_excel_returns_bytes() {
291        let (lines, total) = create_test_report();
292
293        let result = PcnExporter::export_to_excel("Test Building", &lines, total);
294
295        assert!(result.is_ok());
296        let excel_bytes = result.unwrap();
297        assert!(!excel_bytes.is_empty());
298
299        // Excel (XLSX) files start with PK (ZIP signature)
300        assert_eq!(&excel_bytes[0..2], b"PK");
301    }
302
303    #[test]
304    fn test_export_excel_empty_report() {
305        let result = PcnExporter::export_to_excel("Empty Building", &[], 0.0);
306
307        assert!(result.is_ok());
308        let excel_bytes = result.unwrap();
309        assert!(!excel_bytes.is_empty());
310        assert_eq!(&excel_bytes[0..2], b"PK");
311    }
312
313    #[test]
314    fn test_export_excel_has_correct_row_count() {
315        let (lines, total) = create_test_report();
316
317        let result = PcnExporter::export_to_excel("Test Building", &lines, total);
318
319        assert!(result.is_ok());
320        // Should have header + 3 data rows + total row
321        // We can't easily parse Excel in tests, so just verify no error
322    }
323}