Skip to main content

koprogo_api/domain/services/
pcn_exporter.rs

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