koprogo_api/application/use_cases/
energy_campaign_use_cases.rs

1use std::sync::Arc;
2use uuid::Uuid;
3
4use crate::application::ports::{
5    BuildingRepository, EnergyBillUploadRepository, EnergyCampaignRepository,
6};
7use crate::domain::entities::{CampaignStatus, EnergyCampaign, ProviderOffer};
8
9pub struct EnergyCampaignUseCases {
10    campaign_repo: Arc<dyn EnergyCampaignRepository>,
11    bill_upload_repo: Arc<dyn EnergyBillUploadRepository>,
12    building_repo: Arc<dyn BuildingRepository>,
13}
14
15impl EnergyCampaignUseCases {
16    pub fn new(
17        campaign_repo: Arc<dyn EnergyCampaignRepository>,
18        bill_upload_repo: Arc<dyn EnergyBillUploadRepository>,
19        building_repo: Arc<dyn BuildingRepository>,
20    ) -> Self {
21        Self {
22            campaign_repo,
23            bill_upload_repo,
24            building_repo,
25        }
26    }
27
28    /// Create a new energy campaign
29    pub async fn create_campaign(
30        &self,
31        campaign: EnergyCampaign,
32    ) -> Result<EnergyCampaign, String> {
33        // Validate building exists if building_id is provided
34        if let Some(building_id) = campaign.building_id {
35            let building = self
36                .building_repo
37                .find_by_id(building_id)
38                .await
39                .map_err(|e| format!("Failed to validate building: {}", e))?;
40
41            if building.is_none() {
42                return Err("Building not found".to_string());
43            }
44        }
45
46        self.campaign_repo.create(&campaign).await
47    }
48
49    /// Get campaign by ID
50    pub async fn get_campaign(&self, id: Uuid) -> Result<Option<EnergyCampaign>, String> {
51        self.campaign_repo.find_by_id(id).await
52    }
53
54    /// Get all campaigns for an organization
55    pub async fn get_campaigns_by_organization(
56        &self,
57        organization_id: Uuid,
58    ) -> Result<Vec<EnergyCampaign>, String> {
59        self.campaign_repo
60            .find_by_organization(organization_id)
61            .await
62    }
63
64    /// Get all campaigns for a building
65    pub async fn get_campaigns_by_building(
66        &self,
67        building_id: Uuid,
68    ) -> Result<Vec<EnergyCampaign>, String> {
69        self.campaign_repo.find_by_building(building_id).await
70    }
71
72    /// Update campaign status
73    pub async fn update_campaign_status(
74        &self,
75        id: Uuid,
76        new_status: CampaignStatus,
77    ) -> Result<EnergyCampaign, String> {
78        let mut campaign = self
79            .campaign_repo
80            .find_by_id(id)
81            .await?
82            .ok_or_else(|| "Campaign not found".to_string())?;
83
84        campaign.status = new_status;
85        self.campaign_repo.update(&campaign).await
86    }
87
88    /// Add provider offer to campaign
89    pub async fn add_offer(
90        &self,
91        campaign_id: Uuid,
92        offer: ProviderOffer,
93    ) -> Result<ProviderOffer, String> {
94        // Verify campaign exists and is in Negotiating status
95        let campaign = self
96            .campaign_repo
97            .find_by_id(campaign_id)
98            .await?
99            .ok_or_else(|| "Campaign not found".to_string())?;
100
101        if campaign.status != CampaignStatus::Negotiating {
102            return Err("Campaign must be in Negotiating status to add offers".to_string());
103        }
104
105        self.campaign_repo.add_offer(campaign_id, &offer).await
106    }
107
108    /// Get all offers for a campaign
109    pub async fn get_campaign_offers(
110        &self,
111        campaign_id: Uuid,
112    ) -> Result<Vec<ProviderOffer>, String> {
113        self.campaign_repo.get_offers(campaign_id).await
114    }
115
116    /// Select winning offer
117    pub async fn select_offer(
118        &self,
119        campaign_id: Uuid,
120        offer_id: Uuid,
121    ) -> Result<EnergyCampaign, String> {
122        let mut campaign = self
123            .campaign_repo
124            .find_by_id(campaign_id)
125            .await?
126            .ok_or_else(|| "Campaign not found".to_string())?;
127
128        // Validate offer exists
129        let offer = self
130            .campaign_repo
131            .find_offer_by_id(offer_id)
132            .await?
133            .ok_or_else(|| "Offer not found".to_string())?;
134
135        if offer.campaign_id != campaign_id {
136            return Err("Offer does not belong to this campaign".to_string());
137        }
138
139        campaign.select_offer(offer_id)?;
140        self.campaign_repo.update(&campaign).await
141    }
142
143    /// Calculate and update campaign aggregations (called after bill uploads)
144    pub async fn update_campaign_aggregation(
145        &self,
146        campaign_id: Uuid,
147        encryption_key: &[u8; 32],
148    ) -> Result<(), String> {
149        // Get all verified bill uploads for this campaign
150        let uploads = self
151            .bill_upload_repo
152            .find_verified_by_campaign(campaign_id)
153            .await?;
154
155        if uploads.is_empty() {
156            return Ok(());
157        }
158
159        // Decrypt and aggregate consumption data
160        let mut total_kwh_electricity = 0.0;
161        let mut total_kwh_gas = 0.0;
162        let mut count_electricity = 0;
163        let mut count_gas = 0;
164
165        for upload in &uploads {
166            let kwh = upload.decrypt_kwh(encryption_key)?;
167
168            match upload.energy_type {
169                crate::domain::entities::EnergyType::Electricity => {
170                    total_kwh_electricity += kwh;
171                    count_electricity += 1;
172                }
173                crate::domain::entities::EnergyType::Gas => {
174                    total_kwh_gas += kwh;
175                    count_gas += 1;
176                }
177                crate::domain::entities::EnergyType::Both => {
178                    // For "Both", split 50/50 (simplified logic)
179                    total_kwh_electricity += kwh / 2.0;
180                    total_kwh_gas += kwh / 2.0;
181                    count_electricity += 1;
182                    count_gas += 1;
183                }
184            }
185        }
186
187        let total_kwh_elec = if count_electricity > 0 {
188            Some(total_kwh_electricity)
189        } else {
190            None
191        };
192
193        let total_kwh_g = if count_gas > 0 {
194            Some(total_kwh_gas)
195        } else {
196            None
197        };
198
199        let avg_kwh = if !uploads.is_empty() {
200            Some((total_kwh_electricity + total_kwh_gas) / uploads.len() as f64)
201        } else {
202            None
203        };
204
205        // Update campaign with aggregated data
206        self.campaign_repo
207            .update_aggregation(campaign_id, total_kwh_elec, total_kwh_g, avg_kwh)
208            .await
209    }
210
211    /// Get campaign statistics (anonymized)
212    pub async fn get_campaign_stats(&self, campaign_id: Uuid) -> Result<CampaignStats, String> {
213        let campaign = self
214            .campaign_repo
215            .find_by_id(campaign_id)
216            .await?
217            .ok_or_else(|| "Campaign not found".to_string())?;
218
219        // Get building total units if building_id is set
220        let total_units = if let Some(building_id) = campaign.building_id {
221            let building = self
222                .building_repo
223                .find_by_id(building_id)
224                .await?
225                .ok_or_else(|| "Building not found".to_string())?;
226            building.total_units
227        } else {
228            // For multi-building campaigns, we'd need to sum all buildings
229            0
230        };
231
232        let participation_rate = if total_units > 0 {
233            campaign.participation_rate(total_units)
234        } else {
235            0.0
236        };
237
238        let can_negotiate = if total_units > 0 {
239            campaign.can_negotiate(total_units)
240        } else {
241            false
242        };
243
244        Ok(CampaignStats {
245            total_participants: campaign.total_participants,
246            participation_rate,
247            total_kwh_electricity: campaign.total_kwh_electricity,
248            total_kwh_gas: campaign.total_kwh_gas,
249            avg_kwh_per_unit: campaign.avg_kwh_per_unit,
250            can_negotiate,
251            estimated_savings_pct: campaign.estimated_savings_pct,
252        })
253    }
254
255    /// Finalize campaign (after final vote)
256    pub async fn finalize_campaign(&self, id: Uuid) -> Result<EnergyCampaign, String> {
257        let mut campaign = self
258            .campaign_repo
259            .find_by_id(id)
260            .await?
261            .ok_or_else(|| "Campaign not found".to_string())?;
262
263        campaign.finalize()?;
264        self.campaign_repo.update(&campaign).await
265    }
266
267    /// Complete campaign (contracts signed)
268    pub async fn complete_campaign(&self, id: Uuid) -> Result<EnergyCampaign, String> {
269        let mut campaign = self
270            .campaign_repo
271            .find_by_id(id)
272            .await?
273            .ok_or_else(|| "Campaign not found".to_string())?;
274
275        campaign.complete()?;
276        self.campaign_repo.update(&campaign).await
277    }
278
279    /// Cancel campaign
280    pub async fn cancel_campaign(&self, id: Uuid) -> Result<EnergyCampaign, String> {
281        let mut campaign = self
282            .campaign_repo
283            .find_by_id(id)
284            .await?
285            .ok_or_else(|| "Campaign not found".to_string())?;
286
287        campaign.cancel()?;
288        self.campaign_repo.update(&campaign).await
289    }
290
291    /// Delete campaign
292    pub async fn delete_campaign(&self, id: Uuid) -> Result<(), String> {
293        self.campaign_repo.delete(id).await
294    }
295}
296
297#[derive(Debug, serde::Serialize, serde::Deserialize)]
298pub struct CampaignStats {
299    pub total_participants: i32,
300    pub participation_rate: f64,
301    pub total_kwh_electricity: Option<f64>,
302    pub total_kwh_gas: Option<f64>,
303    pub avg_kwh_per_unit: Option<f64>,
304    pub can_negotiate: bool,
305    pub estimated_savings_pct: Option<f64>,
306}