Skip to main content

koprogo_api/application/use_cases/
pcn_use_cases.rs

1use crate::application::dto::{PcnReportLineDto, PcnReportRequest, PcnReportResponse};
2use crate::application::ports::ExpenseRepository;
3use crate::domain::services::{PcnExporter, PcnMapper};
4use chrono::Utc;
5use std::sync::Arc;
6
7pub struct PcnUseCases {
8    expense_repo: Arc<dyn ExpenseRepository>,
9}
10
11impl PcnUseCases {
12    pub fn new(expense_repo: Arc<dyn ExpenseRepository>) -> Self {
13        Self { expense_repo }
14    }
15
16    /// Generate PCN report for a building
17    /// Aggregates expenses by PCN account and returns structured report
18    pub async fn generate_report(
19        &self,
20        request: PcnReportRequest,
21    ) -> Result<PcnReportResponse, String> {
22        // Fetch expenses for the building
23        let all_expenses = self
24            .expense_repo
25            .find_by_building(request.building_id)
26            .await?;
27
28        // Filter by date range if provided
29        let expenses: Vec<_> = all_expenses
30            .into_iter()
31            .filter(|e| {
32                let after_start = request
33                    .start_date
34                    .map(|start| e.expense_date >= start)
35                    .unwrap_or(true);
36                let before_end = request
37                    .end_date
38                    .map(|end| e.expense_date <= end)
39                    .unwrap_or(true);
40                after_start && before_end
41            })
42            .collect();
43
44        // Generate PCN report using domain service
45        let report_lines = PcnMapper::generate_report(&expenses);
46
47        // Calculate totals
48        let total_amount: rust_decimal::Decimal = report_lines.iter().map(|l| l.total_amount).sum();
49        let total_entries: usize = report_lines.iter().map(|l| l.entry_count).sum();
50
51        // Convert to DTOs
52        let lines: Vec<PcnReportLineDto> = report_lines
53            .into_iter()
54            .map(PcnReportLineDto::from)
55            .collect();
56
57        Ok(PcnReportResponse {
58            building_id: request.building_id,
59            generated_at: Utc::now(),
60            period_start: request.start_date,
61            period_end: request.end_date,
62            lines,
63            total_amount,
64            total_entries,
65        })
66    }
67
68    /// Export PCN report as PDF bytes
69    pub async fn export_pdf(
70        &self,
71        building_name: &str,
72        request: PcnReportRequest,
73    ) -> Result<Vec<u8>, String> {
74        // Generate report first
75        let report_response = self.generate_report(request).await?;
76
77        // Convert DTOs back to domain entities for export
78        let report_lines: Vec<_> = report_response
79            .lines
80            .iter()
81            .map(|dto| crate::domain::services::PcnReportLine {
82                account: crate::domain::services::PcnAccount {
83                    code: dto.account_code.clone(),
84                    label_nl: dto.account_label_nl.clone(),
85                    label_fr: dto.account_label_fr.clone(),
86                    label_de: dto.account_label_de.clone(),
87                    label_en: dto.account_label_en.clone(),
88                },
89                total_amount: dto.total_amount,
90                entry_count: dto.entry_count,
91            })
92            .collect();
93
94        PcnExporter::export_to_pdf(building_name, &report_lines, report_response.total_amount)
95    }
96
97    /// Export PCN report as Excel bytes
98    pub async fn export_excel(
99        &self,
100        building_name: &str,
101        request: PcnReportRequest,
102    ) -> Result<Vec<u8>, String> {
103        // Generate report first
104        let report_response = self.generate_report(request).await?;
105
106        // Convert DTOs back to domain entities for export
107        let report_lines: Vec<_> = report_response
108            .lines
109            .iter()
110            .map(|dto| crate::domain::services::PcnReportLine {
111                account: crate::domain::services::PcnAccount {
112                    code: dto.account_code.clone(),
113                    label_nl: dto.account_label_nl.clone(),
114                    label_fr: dto.account_label_fr.clone(),
115                    label_de: dto.account_label_de.clone(),
116                    label_en: dto.account_label_en.clone(),
117                },
118                total_amount: dto.total_amount,
119                entry_count: dto.entry_count,
120            })
121            .collect();
122
123        PcnExporter::export_to_excel(building_name, &report_lines, report_response.total_amount)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::application::dto::{ExpenseFilters, PageRequest};
131    use crate::application::ports::ExpenseRepository;
132    use crate::domain::entities::{Expense, ExpenseCategory};
133    use async_trait::async_trait;
134    use chrono::Utc;
135    use uuid::Uuid;
136
137    struct MockExpenseRepository {
138        expenses: Vec<Expense>,
139    }
140
141    #[async_trait]
142    impl ExpenseRepository for MockExpenseRepository {
143        async fn enregistrer_lignes_de_facture(
144            &self,
145            _expense_id: Uuid,
146            _lignes: &[crate::application::ports::expense_repository::LigneDeFacture],
147        ) -> Result<(), String> {
148            // Mock : rien à enregistrer. Le port n'offre pas d'implémentation
149            // par défaut, précisément pour que ce choix soit écrit ici plutôt
150            // que subi partout.
151            Ok(())
152        }
153
154        async fn create(&self, _expense: &Expense) -> Result<Expense, String> {
155            unimplemented!()
156        }
157
158        async fn find_by_id(&self, _id: Uuid) -> Result<Option<Expense>, String> {
159            unimplemented!()
160        }
161
162        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Expense>, String> {
163            Ok(self
164                .expenses
165                .iter()
166                .filter(|e| e.building_id == building_id)
167                .cloned()
168                .collect())
169        }
170
171        async fn update(&self, _expense: &Expense) -> Result<Expense, String> {
172            unimplemented!()
173        }
174
175        async fn delete(&self, _id: Uuid) -> Result<bool, String> {
176            unimplemented!()
177        }
178
179        async fn find_all_paginated(
180            &self,
181            _page_request: &PageRequest,
182            _filters: &ExpenseFilters,
183        ) -> Result<(Vec<Expense>, i64), String> {
184            unimplemented!()
185        }
186    }
187
188    fn create_test_expense(
189        organization_id: Uuid,
190        building_id: Uuid,
191        category: ExpenseCategory,
192        amount: rust_decimal::Decimal,
193    ) -> Expense {
194        Expense::new(
195            Uuid::new_v4(), // acp_id
196            organization_id,
197            building_id,
198            category,
199            "Test expense".to_string(),
200            amount,
201            Utc::now(),
202            Some("Supplier".to_string()),
203            Some("INV-001".to_string()),
204            None, // account_code
205        )
206        .unwrap()
207    }
208
209    #[tokio::test]
210    async fn test_generate_report_success() {
211        let org_id = Uuid::new_v4();
212        let building_id = Uuid::new_v4();
213        let expenses = vec![
214            create_test_expense(
215                org_id,
216                building_id,
217                ExpenseCategory::Maintenance,
218                rust_decimal_macros::dec!(100),
219            ),
220            create_test_expense(
221                org_id,
222                building_id,
223                ExpenseCategory::Maintenance,
224                rust_decimal_macros::dec!(150),
225            ),
226            create_test_expense(
227                org_id,
228                building_id,
229                ExpenseCategory::Utilities,
230                rust_decimal_macros::dec!(50),
231            ),
232        ];
233
234        let repo = Arc::new(MockExpenseRepository { expenses });
235        let use_cases = PcnUseCases::new(repo);
236
237        let request = PcnReportRequest {
238            building_id,
239            start_date: None,
240            end_date: None,
241        };
242
243        let result = use_cases.generate_report(request).await;
244        assert!(result.is_ok());
245
246        let response = result.unwrap();
247        assert_eq!(response.building_id, building_id);
248        assert_eq!(response.lines.len(), 2); // Maintenance + Utilities
249        assert_eq!(response.total_amount, rust_decimal_macros::dec!(300));
250        assert_eq!(response.total_entries, 3);
251
252        // Verify Maintenance account (611)
253        let maintenance = response
254            .lines
255            .iter()
256            .find(|l| l.account_code == "611")
257            .unwrap();
258        assert_eq!(maintenance.total_amount, rust_decimal_macros::dec!(250));
259        assert_eq!(maintenance.entry_count, 2);
260    }
261
262    #[tokio::test]
263    async fn test_generate_report_empty() {
264        let building_id = Uuid::new_v4();
265        let repo = Arc::new(MockExpenseRepository { expenses: vec![] });
266        let use_cases = PcnUseCases::new(repo);
267
268        let request = PcnReportRequest {
269            building_id,
270            start_date: None,
271            end_date: None,
272        };
273
274        let result = use_cases.generate_report(request).await;
275        assert!(result.is_ok());
276
277        let response = result.unwrap();
278        assert_eq!(response.lines.len(), 0);
279        assert_eq!(response.total_amount, rust_decimal::Decimal::ZERO);
280        assert_eq!(response.total_entries, 0);
281    }
282}