koprogo_api/domain/entities/
energy_campaign.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Campagne d'achat groupé d'énergie
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct EnergyCampaign {
8    pub id: Uuid,
9    pub organization_id: Uuid,
10    pub building_id: Option<Uuid>, // NULL si multi-buildings
11
12    // Méta
13    pub campaign_name: String,
14    pub campaign_type: CampaignType,
15    pub status: CampaignStatus,
16
17    // Timeline
18    pub deadline_participation: DateTime<Utc>,
19    pub deadline_vote: Option<DateTime<Utc>>,
20    pub contract_start_date: Option<DateTime<Utc>>,
21
22    // Configuration
23    pub energy_types: Vec<EnergyType>,
24    pub contract_duration_months: i32, // 12, 24, 36
25    pub contract_type: ContractType,   // Fixed, Variable
26
27    // Agrégation (données anonymes)
28    pub total_participants: i32,
29    pub total_kwh_electricity: Option<f64>,
30    pub total_kwh_gas: Option<f64>,
31    pub avg_kwh_per_unit: Option<f64>,
32
33    // Résultats négociation
34    pub offers_received: Vec<ProviderOffer>,
35    pub selected_offer_id: Option<Uuid>,
36    pub estimated_savings_pct: Option<f64>,
37
38    // Audit
39    pub created_by: Uuid, // User ID (syndic)
40    pub created_at: DateTime<Utc>,
41    pub updated_at: DateTime<Utc>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub enum CampaignType {
46    BuyingGroup,      // Achat groupé classique
47    CollectiveSwitch, // Switch collectif
48}
49
50impl std::fmt::Display for CampaignType {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            CampaignType::BuyingGroup => write!(f, "BuyingGroup"),
54            CampaignType::CollectiveSwitch => write!(f, "CollectiveSwitch"),
55        }
56    }
57}
58
59impl std::str::FromStr for CampaignType {
60    type Err = String;
61
62    fn from_str(s: &str) -> Result<Self, Self::Err> {
63        match s {
64            "BuyingGroup" => Ok(CampaignType::BuyingGroup),
65            "CollectiveSwitch" => Ok(CampaignType::CollectiveSwitch),
66            _ => Err(format!("Invalid campaign type: {}", s)),
67        }
68    }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub enum CampaignStatus {
73    Draft,             // En préparation
74    AwaitingAGVote,    // En attente vote AG
75    CollectingData,    // Collecte factures
76    Negotiating,       // Négociation courtier
77    AwaitingFinalVote, // Vote final offre
78    Finalized,         // Switch en cours
79    Completed,         // Contrats actifs
80    Cancelled,         // Annulée
81}
82
83impl std::fmt::Display for CampaignStatus {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            CampaignStatus::Draft => write!(f, "Draft"),
87            CampaignStatus::AwaitingAGVote => write!(f, "AwaitingAGVote"),
88            CampaignStatus::CollectingData => write!(f, "CollectingData"),
89            CampaignStatus::Negotiating => write!(f, "Negotiating"),
90            CampaignStatus::AwaitingFinalVote => write!(f, "AwaitingFinalVote"),
91            CampaignStatus::Finalized => write!(f, "Finalized"),
92            CampaignStatus::Completed => write!(f, "Completed"),
93            CampaignStatus::Cancelled => write!(f, "Cancelled"),
94        }
95    }
96}
97
98impl std::str::FromStr for CampaignStatus {
99    type Err = String;
100
101    fn from_str(s: &str) -> Result<Self, Self::Err> {
102        match s {
103            "Draft" => Ok(CampaignStatus::Draft),
104            "AwaitingAGVote" => Ok(CampaignStatus::AwaitingAGVote),
105            "CollectingData" => Ok(CampaignStatus::CollectingData),
106            "Negotiating" => Ok(CampaignStatus::Negotiating),
107            "AwaitingFinalVote" => Ok(CampaignStatus::AwaitingFinalVote),
108            "Finalized" => Ok(CampaignStatus::Finalized),
109            "Completed" => Ok(CampaignStatus::Completed),
110            "Cancelled" => Ok(CampaignStatus::Cancelled),
111            _ => Err(format!("Invalid campaign status: {}", s)),
112        }
113    }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117pub enum EnergyType {
118    Electricity,
119    Gas,
120    Both,
121}
122
123impl std::fmt::Display for EnergyType {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            EnergyType::Electricity => write!(f, "Electricity"),
127            EnergyType::Gas => write!(f, "Gas"),
128            EnergyType::Both => write!(f, "Both"),
129        }
130    }
131}
132
133impl std::str::FromStr for EnergyType {
134    type Err = String;
135
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        match s {
138            "Electricity" => Ok(EnergyType::Electricity),
139            "Gas" => Ok(EnergyType::Gas),
140            "Both" => Ok(EnergyType::Both),
141            _ => Err(format!("Invalid energy type: {}", s)),
142        }
143    }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub enum ContractType {
148    Fixed,    // Prix fixe
149    Variable, // Prix variable (indexé)
150}
151
152impl std::fmt::Display for ContractType {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            ContractType::Fixed => write!(f, "Fixed"),
156            ContractType::Variable => write!(f, "Variable"),
157        }
158    }
159}
160
161impl std::str::FromStr for ContractType {
162    type Err = String;
163
164    fn from_str(s: &str) -> Result<Self, Self::Err> {
165        match s {
166            "Fixed" => Ok(ContractType::Fixed),
167            "Variable" => Ok(ContractType::Variable),
168            _ => Err(format!("Invalid contract type: {}", s)),
169        }
170    }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174pub struct ProviderOffer {
175    pub id: Uuid,
176    pub campaign_id: Uuid,
177    pub provider_name: String,
178    pub price_kwh_electricity: Option<f64>,
179    pub price_kwh_gas: Option<f64>,
180    pub fixed_monthly_fee: f64,
181    pub green_energy_pct: f64, // 0-100
182    pub contract_duration_months: i32,
183    pub estimated_savings_pct: f64,
184    pub offer_valid_until: DateTime<Utc>,
185    pub created_at: DateTime<Utc>,
186    pub updated_at: DateTime<Utc>,
187}
188
189impl ProviderOffer {
190    /// Créer nouvelle offre fournisseur
191    pub fn new(
192        campaign_id: Uuid,
193        provider_name: String,
194        price_kwh_electricity: Option<f64>,
195        price_kwh_gas: Option<f64>,
196        fixed_monthly_fee: f64,
197        green_energy_pct: f64,
198        contract_duration_months: i32,
199        estimated_savings_pct: f64,
200        offer_valid_until: DateTime<Utc>,
201    ) -> Result<Self, String> {
202        if provider_name.trim().is_empty() {
203            return Err("Provider name cannot be empty".to_string());
204        }
205
206        if green_energy_pct < 0.0 || green_energy_pct > 100.0 {
207            return Err("Green energy percentage must be between 0 and 100".to_string());
208        }
209
210        if contract_duration_months <= 0 {
211            return Err("Contract duration must be positive".to_string());
212        }
213
214        if offer_valid_until <= Utc::now() {
215            return Err("Offer validity date must be in the future".to_string());
216        }
217
218        Ok(Self {
219            id: Uuid::new_v4(),
220            campaign_id,
221            provider_name,
222            price_kwh_electricity,
223            price_kwh_gas,
224            fixed_monthly_fee,
225            green_energy_pct,
226            contract_duration_months,
227            estimated_savings_pct,
228            offer_valid_until,
229            created_at: Utc::now(),
230            updated_at: Utc::now(),
231        })
232    }
233
234    /// Calculer score vert (pour nudge behavioral)
235    pub fn green_score(&self) -> i32 {
236        if self.green_energy_pct >= 100.0 {
237            10
238        } else if self.green_energy_pct >= 50.0 {
239            5
240        } else {
241            0
242        }
243    }
244}
245
246impl EnergyCampaign {
247    /// Créer nouvelle campagne
248    pub fn new(
249        organization_id: Uuid,
250        building_id: Option<Uuid>,
251        campaign_name: String,
252        deadline_participation: DateTime<Utc>,
253        energy_types: Vec<EnergyType>,
254        created_by: Uuid,
255    ) -> Result<Self, String> {
256        if campaign_name.trim().is_empty() {
257            return Err("Campaign name cannot be empty".to_string());
258        }
259
260        if energy_types.is_empty() {
261            return Err("At least one energy type required".to_string());
262        }
263
264        if deadline_participation <= Utc::now() {
265            return Err("Deadline must be in the future".to_string());
266        }
267
268        Ok(Self {
269            id: Uuid::new_v4(),
270            organization_id,
271            building_id,
272            campaign_name,
273            campaign_type: CampaignType::BuyingGroup,
274            status: CampaignStatus::Draft,
275            deadline_participation,
276            deadline_vote: None,
277            contract_start_date: None,
278            energy_types,
279            contract_duration_months: 12,
280            contract_type: ContractType::Fixed,
281            total_participants: 0,
282            total_kwh_electricity: None,
283            total_kwh_gas: None,
284            avg_kwh_per_unit: None,
285            offers_received: Vec::new(),
286            selected_offer_id: None,
287            estimated_savings_pct: None,
288            created_by,
289            created_at: Utc::now(),
290            updated_at: Utc::now(),
291        })
292    }
293
294    /// Lancer collecte données (après vote AG)
295    pub fn start_data_collection(&mut self) -> Result<(), String> {
296        if self.status != CampaignStatus::AwaitingAGVote {
297            return Err("Campaign must be in AwaitingAGVote status".to_string());
298        }
299
300        self.status = CampaignStatus::CollectingData;
301        self.updated_at = Utc::now();
302        Ok(())
303    }
304
305    /// Calculer taux de participation
306    pub fn participation_rate(&self, total_units: i32) -> f64 {
307        if total_units == 0 {
308            return 0.0;
309        }
310        (self.total_participants as f64 / total_units as f64) * 100.0
311    }
312
313    /// Vérifier si éligible négociation (min 60% participation)
314    pub fn can_negotiate(&self, total_units: i32) -> bool {
315        self.participation_rate(total_units) >= 60.0
316    }
317
318    /// Ajouter une offre fournisseur
319    pub fn add_offer(&mut self, offer: ProviderOffer) -> Result<(), String> {
320        if self.status != CampaignStatus::Negotiating {
321            return Err("Campaign must be in Negotiating status".to_string());
322        }
323
324        self.offers_received.push(offer);
325        self.updated_at = Utc::now();
326        Ok(())
327    }
328
329    /// Sélectionner offre gagnante
330    pub fn select_offer(&mut self, offer_id: Uuid) -> Result<(), String> {
331        if self.status != CampaignStatus::AwaitingFinalVote
332            && self.status != CampaignStatus::Negotiating
333        {
334            return Err("Campaign must be in AwaitingFinalVote or Negotiating status".to_string());
335        }
336
337        // Vérifier que l'offre existe
338        if !self.offers_received.iter().any(|o| o.id == offer_id) {
339            return Err("Offer not found in campaign".to_string());
340        }
341
342        self.selected_offer_id = Some(offer_id);
343        self.updated_at = Utc::now();
344        Ok(())
345    }
346
347    /// Finaliser campagne (après vote final)
348    pub fn finalize(&mut self) -> Result<(), String> {
349        if self.status != CampaignStatus::AwaitingFinalVote {
350            return Err("Campaign must be in AwaitingFinalVote status".to_string());
351        }
352
353        if self.selected_offer_id.is_none() {
354            return Err("No offer selected".to_string());
355        }
356
357        self.status = CampaignStatus::Finalized;
358        self.updated_at = Utc::now();
359        Ok(())
360    }
361
362    /// Marquer comme complétée (contrats signés)
363    pub fn complete(&mut self) -> Result<(), String> {
364        if self.status != CampaignStatus::Finalized {
365            return Err("Campaign must be in Finalized status".to_string());
366        }
367
368        self.status = CampaignStatus::Completed;
369        self.updated_at = Utc::now();
370        Ok(())
371    }
372
373    /// Annuler campagne
374    pub fn cancel(&mut self) -> Result<(), String> {
375        if self.status == CampaignStatus::Completed || self.status == CampaignStatus::Cancelled {
376            return Err("Cannot cancel completed or already cancelled campaign".to_string());
377        }
378
379        self.status = CampaignStatus::Cancelled;
380        self.updated_at = Utc::now();
381        Ok(())
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_create_campaign_success() {
391        let campaign = EnergyCampaign::new(
392            Uuid::new_v4(),
393            Some(Uuid::new_v4()),
394            "Campagne Hiver 2025-2026".to_string(),
395            Utc::now() + chrono::Duration::days(30),
396            vec![EnergyType::Electricity],
397            Uuid::new_v4(),
398        );
399
400        assert!(campaign.is_ok());
401        let campaign = campaign.unwrap();
402        assert_eq!(campaign.status, CampaignStatus::Draft);
403        assert_eq!(campaign.total_participants, 0);
404        assert_eq!(campaign.contract_duration_months, 12);
405    }
406
407    #[test]
408    fn test_create_campaign_empty_name() {
409        let result = EnergyCampaign::new(
410            Uuid::new_v4(),
411            Some(Uuid::new_v4()),
412            "".to_string(),
413            Utc::now() + chrono::Duration::days(30),
414            vec![EnergyType::Electricity],
415            Uuid::new_v4(),
416        );
417
418        assert!(result.is_err());
419        assert_eq!(result.unwrap_err(), "Campaign name cannot be empty");
420    }
421
422    #[test]
423    fn test_create_campaign_no_energy_types() {
424        let result = EnergyCampaign::new(
425            Uuid::new_v4(),
426            Some(Uuid::new_v4()),
427            "Campagne Test".to_string(),
428            Utc::now() + chrono::Duration::days(30),
429            vec![],
430            Uuid::new_v4(),
431        );
432
433        assert!(result.is_err());
434        assert_eq!(result.unwrap_err(), "At least one energy type required");
435    }
436
437    #[test]
438    fn test_create_campaign_deadline_in_past() {
439        let result = EnergyCampaign::new(
440            Uuid::new_v4(),
441            Some(Uuid::new_v4()),
442            "Campagne Test".to_string(),
443            Utc::now() - chrono::Duration::days(1),
444            vec![EnergyType::Electricity],
445            Uuid::new_v4(),
446        );
447
448        assert!(result.is_err());
449        assert_eq!(result.unwrap_err(), "Deadline must be in the future");
450    }
451
452    #[test]
453    fn test_participation_rate() {
454        let mut campaign = EnergyCampaign::new(
455            Uuid::new_v4(),
456            Some(Uuid::new_v4()),
457            "Campagne Test".to_string(),
458            Utc::now() + chrono::Duration::days(30),
459            vec![EnergyType::Electricity],
460            Uuid::new_v4(),
461        )
462        .unwrap();
463
464        campaign.total_participants = 18;
465        let rate = campaign.participation_rate(25);
466        assert_eq!(rate, 72.0);
467    }
468
469    #[test]
470    fn test_can_negotiate() {
471        let mut campaign = EnergyCampaign::new(
472            Uuid::new_v4(),
473            Some(Uuid::new_v4()),
474            "Campagne Test".to_string(),
475            Utc::now() + chrono::Duration::days(30),
476            vec![EnergyType::Electricity],
477            Uuid::new_v4(),
478        )
479        .unwrap();
480
481        campaign.total_participants = 15; // 60% de 25
482        assert!(campaign.can_negotiate(25));
483
484        campaign.total_participants = 14; // 56% de 25
485        assert!(!campaign.can_negotiate(25));
486    }
487
488    #[test]
489    fn test_provider_offer_creation() {
490        let offer = ProviderOffer::new(
491            Uuid::new_v4(),
492            "Lampiris".to_string(),
493            Some(0.27),
494            None,
495            12.50,
496            100.0,
497            12,
498            15.0,
499            Utc::now() + chrono::Duration::days(30),
500        );
501
502        assert!(offer.is_ok());
503        let offer = offer.unwrap();
504        assert_eq!(offer.provider_name, "Lampiris");
505        assert_eq!(offer.green_score(), 10);
506    }
507
508    #[test]
509    fn test_green_score() {
510        let offer_100 = ProviderOffer::new(
511            Uuid::new_v4(),
512            "Lampiris".to_string(),
513            Some(0.27),
514            None,
515            12.50,
516            100.0,
517            12,
518            15.0,
519            Utc::now() + chrono::Duration::days(30),
520        )
521        .unwrap();
522        assert_eq!(offer_100.green_score(), 10);
523
524        let offer_75 = ProviderOffer::new(
525            Uuid::new_v4(),
526            "Engie".to_string(),
527            Some(0.25),
528            None,
529            12.50,
530            75.0,
531            12,
532            18.0,
533            Utc::now() + chrono::Duration::days(30),
534        )
535        .unwrap();
536        assert_eq!(offer_75.green_score(), 5);
537
538        let offer_30 = ProviderOffer::new(
539            Uuid::new_v4(),
540            "Luminus".to_string(),
541            Some(0.26),
542            None,
543            12.50,
544            30.0,
545            12,
546            16.0,
547            Utc::now() + chrono::Duration::days(30),
548        )
549        .unwrap();
550        assert_eq!(offer_30.green_score(), 0);
551    }
552
553    #[test]
554    fn test_workflow_state_machine() {
555        let mut campaign = EnergyCampaign::new(
556            Uuid::new_v4(),
557            Some(Uuid::new_v4()),
558            "Campagne Test".to_string(),
559            Utc::now() + chrono::Duration::days(30),
560            vec![EnergyType::Electricity],
561            Uuid::new_v4(),
562        )
563        .unwrap();
564
565        // Draft → AwaitingAGVote
566        campaign.status = CampaignStatus::AwaitingAGVote;
567
568        // AwaitingAGVote → CollectingData
569        assert!(campaign.start_data_collection().is_ok());
570        assert_eq!(campaign.status, CampaignStatus::CollectingData);
571
572        // CollectingData → Negotiating
573        campaign.status = CampaignStatus::Negotiating;
574
575        // Ajouter offre
576        let offer = ProviderOffer::new(
577            campaign.id,
578            "Lampiris".to_string(),
579            Some(0.27),
580            None,
581            12.50,
582            100.0,
583            12,
584            15.0,
585            Utc::now() + chrono::Duration::days(30),
586        )
587        .unwrap();
588        assert!(campaign.add_offer(offer.clone()).is_ok());
589
590        // Negotiating → AwaitingFinalVote
591        campaign.status = CampaignStatus::AwaitingFinalVote;
592
593        // Sélectionner offre
594        assert!(campaign.select_offer(offer.id).is_ok());
595        assert_eq!(campaign.selected_offer_id, Some(offer.id));
596
597        // Finaliser
598        assert!(campaign.finalize().is_ok());
599        assert_eq!(campaign.status, CampaignStatus::Finalized);
600
601        // Compléter
602        assert!(campaign.complete().is_ok());
603        assert_eq!(campaign.status, CampaignStatus::Completed);
604    }
605
606    #[test]
607    fn test_cancel_campaign() {
608        let mut campaign = EnergyCampaign::new(
609            Uuid::new_v4(),
610            Some(Uuid::new_v4()),
611            "Campagne Test".to_string(),
612            Utc::now() + chrono::Duration::days(30),
613            vec![EnergyType::Electricity],
614            Uuid::new_v4(),
615        )
616        .unwrap();
617
618        assert!(campaign.cancel().is_ok());
619        assert_eq!(campaign.status, CampaignStatus::Cancelled);
620
621        // Cannot cancel twice
622        assert!(campaign.cancel().is_err());
623    }
624}