1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct EnergyCampaign {
9 pub id: Uuid,
10 pub organization_id: Uuid,
11 pub building_id: Option<Uuid>, pub campaign_name: String,
15 pub campaign_type: CampaignType,
16 pub status: CampaignStatus,
17
18 pub deadline_participation: DateTime<Utc>,
20 pub deadline_vote: Option<DateTime<Utc>>,
21 pub contract_start_date: Option<DateTime<Utc>>,
22
23 pub energy_types: Vec<EnergyType>,
25 pub contract_duration_months: i32, pub contract_type: ContractType, 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 pub offers_received: Vec<ProviderOffer>,
36 pub selected_offer_id: Option<Uuid>,
37 pub estimated_savings_pct: Option<f64>,
38
39 pub created_by: Uuid, 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, CollectiveSwitch, }
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, AwaitingAGVote, CollectingData, Negotiating, AwaitingFinalVote, Finalized, Completed, Cancelled, }
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, Variable, }
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 pub price_kwh_electricity: Option<Decimal>,
190 pub price_kwh_gas: Option<Decimal>,
192 pub fixed_monthly_fee: Decimal,
195 pub green_energy_pct: f64, 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 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 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 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 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 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 pub fn can_negotiate(&self, total_units: i32) -> bool {
333 self.participation_rate(total_units) >= 60.0
334 }
335
336 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 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 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 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 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 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; assert!(campaign.can_negotiate(25));
501
502 campaign.total_participants = 14; 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 campaign.status = CampaignStatus::AwaitingAGVote;
585
586 assert!(campaign.start_data_collection().is_ok());
588 assert_eq!(campaign.status, CampaignStatus::CollectingData);
589
590 campaign.status = CampaignStatus::Negotiating;
592
593 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 campaign.status = CampaignStatus::AwaitingFinalVote;
610
611 assert!(campaign.select_offer(offer.id).is_ok());
613 assert_eq!(campaign.selected_offer_id, Some(offer.id));
614
615 assert!(campaign.finalize().is_ok());
617 assert_eq!(campaign.status, CampaignStatus::Finalized);
618
619 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 assert!(campaign.cancel().is_err());
641 }
642}