1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct EnergyCampaign {
8 pub id: Uuid,
9 pub organization_id: Uuid,
10 pub building_id: Option<Uuid>, pub campaign_name: String,
14 pub campaign_type: CampaignType,
15 pub status: CampaignStatus,
16
17 pub deadline_participation: DateTime<Utc>,
19 pub deadline_vote: Option<DateTime<Utc>>,
20 pub contract_start_date: Option<DateTime<Utc>>,
21
22 pub energy_types: Vec<EnergyType>,
24 pub contract_duration_months: i32, pub contract_type: ContractType, 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 pub offers_received: Vec<ProviderOffer>,
35 pub selected_offer_id: Option<Uuid>,
36 pub estimated_savings_pct: Option<f64>,
37
38 pub created_by: Uuid, 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, CollectiveSwitch, }
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, AwaitingAGVote, CollectingData, Negotiating, AwaitingFinalVote, Finalized, Completed, Cancelled, }
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, Variable, }
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, 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 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 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 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 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 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 pub fn can_negotiate(&self, total_units: i32) -> bool {
315 self.participation_rate(total_units) >= 60.0
316 }
317
318 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 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 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 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 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 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; assert!(campaign.can_negotiate(25));
483
484 campaign.total_participants = 14; 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 campaign.status = CampaignStatus::AwaitingAGVote;
567
568 assert!(campaign.start_data_collection().is_ok());
570 assert_eq!(campaign.status, CampaignStatus::CollectingData);
571
572 campaign.status = CampaignStatus::Negotiating;
574
575 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 campaign.status = CampaignStatus::AwaitingFinalVote;
592
593 assert!(campaign.select_offer(offer.id).is_ok());
595 assert_eq!(campaign.selected_offer_id, Some(offer.id));
596
597 assert!(campaign.finalize().is_ok());
599 assert_eq!(campaign.status, CampaignStatus::Finalized);
600
601 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 assert!(campaign.cancel().is_err());
623 }
624}