Skip to main content

koprogo_api/domain/economie_circulaire/
energy_campaign.rs

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