Skip to main content

koprogo_api/domain/services/
pcn_exporter.rs

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