1use std::slice::Iter;
2
3use chrono::{DateTime, Datelike};
4use chrono_tz::Tz;
5use serde::{Serialize, Serializer, ser::SerializeSeq};
6
7use crate::{
8 Country, Language, Money,
9 defs::{Hours, Month, Months},
10 helpers,
11};
12
13#[derive(Debug, Clone, Copy, Serialize)]
14#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
15pub enum Cost {
16 None,
17 Unverified,
19 Fixed(Money),
20 Fuses(&'static [(u16, Money)]),
21 FusesYearlyConsumption(&'static [(u16, Option<u32>, Money)]),
23 FuseRange(&'static [(u16, u16, Money)]),
24}
25
26impl Cost {
27 pub const fn is_unverified(&self) -> bool {
28 matches!(self, Self::Unverified)
29 }
30
31 pub(super) const fn fuses(values: &'static [(u16, Money)]) -> Self {
32 Self::Fuses(values)
33 }
34
35 pub(super) const fn fuse_range(ranges: &'static [(u16, u16, Money)]) -> Self {
36 Self::FuseRange(ranges)
37 }
38
39 pub(super) const fn fuses_with_yearly_consumption(
40 values: &'static [(u16, Option<u32>, Money)],
41 ) -> Cost {
42 Self::FusesYearlyConsumption(values)
43 }
44
45 pub(super) const fn fixed(int: i64, fract: u8) -> Self {
46 Self::Fixed(Money::new(int, fract))
47 }
48
49 pub(super) const fn fixed_yearly(int: i64, fract: u8) -> Self {
50 Self::Fixed(Money::new(int, fract).divide_by(12))
51 }
52
53 pub(super) const fn fixed_subunit(subunit: f64) -> Self {
54 Self::Fixed(Money::new_subunit(subunit))
55 }
56
57 pub(super) const fn divide_by(&self, by: i64) -> Self {
58 match self {
59 Self::None => Self::None,
60 Self::Unverified => Self::Unverified,
61 Self::Fixed(money) => Self::Fixed(money.divide_by(by)),
62 Self::Fuses(items) => panic!(".divide_by() is unsupported on Cost::Fuses"),
63 Self::FusesYearlyConsumption(items) => {
64 panic!(".divide_by() is unsupported on Cost::FuseRangeYearlyConsumption")
65 }
66 Self::FuseRange(items) => panic!(".divide_by() is unsupported on Cost::FuseRange"),
67 }
68 }
69
70 pub const fn cost_for(&self, fuse_size: u16, yearly_consumption: u32) -> Option<Money> {
71 match *self {
72 Cost::None => None,
73 Cost::Unverified => None,
74 Cost::Fixed(money) => Some(money),
75 Cost::Fuses(values) => {
76 let mut i = 0;
77 while i < values.len() {
78 let (fsize, money) = values[i];
79 if fuse_size == fsize {
80 return Some(money);
81 }
82 i += 1;
83 }
84 None
85 }
86 Cost::FusesYearlyConsumption(values) => {
87 let mut i = 0;
88 while i < values.len() {
89 let (fsize, max_consumption, money) = values[i];
90 if fsize == fuse_size {
91 if let Some(max_consumption) = max_consumption {
92 if max_consumption <= yearly_consumption {
93 return Some(money);
94 }
95 } else {
96 return Some(money);
97 }
98 }
99 i += 1;
100 }
101 None
102 }
103 Cost::FuseRange(ranges) => {
104 let mut i = 0;
105 while i < ranges.len() {
106 let (min, max, money) = ranges[i];
107 if fuse_size >= min && fuse_size <= max {
108 return Some(money);
109 }
110 i += 1;
111 }
112 None
113 }
114 }
115 }
116
117 pub(crate) const fn add_vat(&self, country: Country) -> Cost {
118 let rate = match country {
119 Country::SE => 1.25,
120 };
121 match self {
122 Cost::None => Cost::None,
123 Cost::Unverified => Cost::Unverified,
124 Cost::Fixed(money) => Cost::Fixed(money.add_vat(country)),
125 Cost::Fuses(items) => todo!(),
126 Cost::FusesYearlyConsumption(items) => todo!(),
127 Cost::FuseRange(items) => todo!(),
128 }
129 }
130
131 pub(crate) fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
132 match self {
133 Cost::FusesYearlyConsumption(items) => items
134 .iter()
135 .filter(|(fsize, _, _)| *fsize == fuse_size)
136 .any(|(_, yearly_consumption, _)| yearly_consumption.is_some()),
137 _ => false,
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, Serialize)]
143#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
144pub struct CostPeriods {
145 periods: &'static [CostPeriod],
146}
147
148impl CostPeriods {
149 pub(super) const fn new(periods: &'static [CostPeriod]) -> Self {
150 Self { periods }
151 }
152
153 pub fn iter(&self) -> Iter<'_, CostPeriod> {
154 self.periods.iter()
155 }
156
157 pub(crate) fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
158 self.periods
159 .iter()
160 .any(|cp| cp.is_yearly_consumption_based(fuse_size))
161 }
162}
163
164#[derive(Debug, Clone, Serialize)]
166#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
167pub struct CostPeriodsSimple {
168 periods: Vec<CostPeriodSimple>,
169}
170
171impl CostPeriodsSimple {
172 pub(crate) fn new(
173 periods: CostPeriods,
174 fuse_size: u16,
175 yearly_consumption: u32,
176 language: Language,
177 ) -> Self {
178 Self {
179 periods: periods
180 .periods
181 .iter()
182 .flat_map(|period| {
183 CostPeriodSimple::new(period, fuse_size, yearly_consumption, language)
184 })
185 .collect(),
186 }
187 }
188}
189
190#[derive(Debug, Clone, Serialize)]
191#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
192pub struct CostPeriod {
193 cost: Cost,
194 load: LoadType,
195 #[serde(serialize_with = "helpers::skip_nones")]
196 include: [Option<Include>; 2],
197 #[serde(serialize_with = "helpers::skip_nones")]
198 exclude: [Option<Exclude>; 2],
199 divide_kw_by: u8,
201}
202
203#[derive(Debug, Clone, Serialize)]
205#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
206pub(super) struct CostPeriodSimple {
207 cost: Money,
208 load: LoadType,
209 include: Vec<Include>,
210 exclude: Vec<Exclude>,
211 divide_kw_by: u8,
213 info: String,
214}
215
216impl CostPeriodSimple {
217 fn new(
218 period: &CostPeriod,
219 fuse_size: u16,
220 yearly_consumption: u32,
221 language: Language,
222 ) -> Option<Self> {
223 let cost = period.cost().cost_for(fuse_size, yearly_consumption)?;
224 Some(
225 Self {
226 cost,
227 load: period.load,
228 include: period.include.into_iter().flatten().collect(),
229 exclude: period.exclude.into_iter().flatten().collect(),
230 divide_kw_by: period.divide_kw_by,
231 info: Default::default(),
232 }
233 .add_info(language),
234 )
235 }
236
237 fn add_info(mut self, language: Language) -> Self {
238 let mut infos = Vec::new();
239 for include in &self.include {
240 infos.push(include.translate(language));
241 }
242 for exclude in &self.exclude {
243 infos.push(exclude.translate(language).into());
244 }
245 self.info = infos.join(", ");
246 self
247 }
248}
249
250impl CostPeriod {
251 pub(super) const fn builder() -> CostPeriodBuilder {
252 CostPeriodBuilder::new()
253 }
254
255 pub const fn cost(&self) -> Cost {
256 self.cost
257 }
258
259 pub const fn load(&self) -> LoadType {
260 self.load
261 }
262
263 pub fn matches(&self, timestamp: DateTime<Tz>) -> bool {
264 for include in self.include_period_types() {
265 if !include.matches(timestamp) {
266 return false;
267 }
268 }
269
270 for exclude in self.exclude_period_types() {
271 if exclude.matches(timestamp) {
272 return false;
273 }
274 }
275 true
276 }
277
278 fn include_period_types(&self) -> Vec<Include> {
279 self.include.iter().flatten().copied().collect()
280 }
281
282 fn exclude_period_types(&self) -> Vec<Exclude> {
283 self.exclude.iter().flatten().copied().collect()
284 }
285
286 fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
287 self.cost.is_yearly_consumption_based(fuse_size)
288 }
289}
290
291#[derive(Debug, Clone, Copy, Serialize)]
292#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
293pub enum LoadType {
294 Base,
296 Low,
298 High,
300}
301
302pub(super) use LoadType::*;
303
304#[derive(Clone)]
305pub(super) struct CostPeriodBuilder {
306 cost: Cost,
307 load: Option<LoadType>,
308 include: [Option<Include>; 2],
309 exclude: [Option<Exclude>; 2],
310 divide_kw_by: u8,
312}
313
314impl CostPeriodBuilder {
315 pub(super) const fn new() -> Self {
316 Self {
317 cost: Cost::None,
318 load: None,
319 include: [None; 2],
320 exclude: [None; 2],
321 divide_kw_by: 1,
322 }
323 }
324
325 pub(super) const fn build(self) -> CostPeriod {
326 CostPeriod {
327 cost: self.cost,
328 load: self.load.expect("`load` must be specified"),
329 include: self.include,
330 exclude: self.exclude,
331 divide_kw_by: self.divide_kw_by,
332 }
333 }
334
335 pub(super) const fn cost(mut self, cost: Cost) -> Self {
336 self.cost = cost;
337 self
338 }
339
340 pub(super) const fn load(mut self, load: LoadType) -> Self {
341 self.load = Some(load);
342 self
343 }
344
345 pub(super) const fn fixed_cost(mut self, int: i64, fract: u8) -> Self {
346 self.cost = Cost::fixed(int, fract);
347 self
348 }
349
350 pub(super) const fn fixed_cost_subunit(mut self, subunit: f64) -> Self {
351 self.cost = Cost::fixed_subunit(subunit);
352 self
353 }
354
355 pub(super) const fn include(mut self, period_type: Include) -> Self {
356 let mut i = 0;
357 while i < self.include.len() {
358 if self.include[i].is_some() {
359 i += 1;
360 } else {
361 self.include[i] = Some(period_type);
362 return self;
363 }
364 }
365 panic!("Too many includes");
366 }
367
368 pub(super) const fn months(self, from: Month, to: Month) -> Self {
369 self.include(Include::Months(Months::new(from, to)))
370 }
371
372 pub(super) const fn month(self, month: Month) -> Self {
373 self.include(Include::Month(month))
374 }
375
376 pub(super) const fn hours(self, from: u8, to_inclusive: u8) -> Self {
377 self.include(Include::Hours(Hours::new(from, to_inclusive)))
378 }
379
380 pub(super) const fn exclude(mut self, period_type: Exclude) -> Self {
381 let mut i = 0;
382 while i < self.exclude.len() {
383 if self.exclude[i].is_some() {
384 i += 1;
385 } else {
386 self.exclude[i] = Some(period_type);
387 return self;
388 }
389 }
390 panic!("Too many excludes");
391 }
392
393 pub(super) const fn exclude_holidays(self, country: Country) -> Self {
394 self.exclude(Exclude::Holidays(country))
395 }
396
397 pub(super) const fn exclude_weekends(self) -> Self {
398 self.exclude(Exclude::Weekends)
399 }
400
401 pub(super) const fn divide_kw_by(mut self, value: u8) -> Self {
402 self.divide_kw_by = value;
403 self
404 }
405}
406
407#[derive(Debug, Clone, Copy, Serialize)]
408#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
409pub(super) enum Include {
410 Months(Months),
411 Month(Month),
412 Hours(Hours),
413}
414
415impl Include {
416 fn translate(&self, language: Language) -> String {
417 match self {
418 Include::Months(months) => months.translate(language),
419 Include::Month(month) => month.translate(language).into(),
420 Include::Hours(hours) => hours.translate(language),
421 }
422 }
423
424 fn matches(&self, timestamp: DateTime<Tz>) -> bool {
425 match self {
426 Include::Months(months) => months.matches(timestamp),
427 Include::Month(month) => month.matches(timestamp),
428 Include::Hours(hours) => hours.matches(timestamp),
429 }
430 }
431}
432
433#[derive(Debug, Clone, Copy, Serialize)]
434#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
435pub(super) enum Exclude {
436 Weekends,
437 Holidays(Country),
438}
439
440impl Exclude {
441 pub(super) fn translate(&self, language: Language) -> &'static str {
442 match language {
443 Language::En => match self {
444 Exclude::Weekends => "Weekends",
445 Exclude::Holidays(country) => match country {
446 Country::SE => "Swedish holidays",
447 },
448 },
449 Language::Sv => match self {
450 Exclude::Weekends => "Helg",
451 Exclude::Holidays(country) => match country {
452 Country::SE => "Svenska helgdagar",
453 },
454 },
455 }
456 }
457
458 fn matches(&self, timestamp: DateTime<Tz>) -> bool {
459 match self {
460 Exclude::Weekends => (6..=7).contains(×tamp.weekday().number_from_monday()),
461 Exclude::Holidays(country) => country.is_holiday(timestamp.date_naive()),
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::defs::{Hours, Month, Months};
470 use crate::money::Money;
471 use chrono::TimeZone;
472 use chrono_tz::Europe::Stockholm;
473
474 #[test]
475 fn fuse_based_cost() {
476 const FUSE_BASED: Cost = Cost::fuse_range(&[
477 (16, 35, Money::new(54, 0)),
478 (35, u16::MAX, Money::new(108, 50)),
479 ]);
480 assert_eq!(FUSE_BASED.cost_for(10, 0), None);
481 assert_eq!(FUSE_BASED.cost_for(25, 0), Some(Money::new(54, 0)));
482 assert_eq!(FUSE_BASED.cost_for(200, 0), Some(Money::new(108, 50)));
483 }
484
485 #[test]
486 fn include_matches_hours() {
487 let include = Include::Hours(Hours::new(6, 22));
488 let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
489 let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
490
491 assert!(include.matches(timestamp_match));
492 assert!(!include.matches(timestamp_no_match));
493 }
494
495 #[test]
496 fn include_matches_month() {
497 let include = Include::Month(Month::June);
498 let timestamp_match = Stockholm.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
499 let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 7, 15, 12, 0, 0).unwrap();
500
501 assert!(include.matches(timestamp_match));
502 assert!(!include.matches(timestamp_no_match));
503 }
504
505 #[test]
506 fn include_matches_months() {
507 let include = Include::Months(Months::new(Month::November, Month::March));
508 let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
509 let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 7, 15, 12, 0, 0).unwrap();
510
511 assert!(include.matches(timestamp_match));
512 assert!(!include.matches(timestamp_no_match));
513 }
514
515 #[test]
516 fn exclude_matches_weekends_saturday() {
517 let exclude = Exclude::Weekends;
518 let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 4, 12, 0, 0).unwrap();
520 assert!(exclude.matches(timestamp));
521 }
522
523 #[test]
524 fn exclude_matches_weekends_sunday() {
525 let exclude = Exclude::Weekends;
526 let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 5, 12, 0, 0).unwrap();
528 assert!(exclude.matches(timestamp));
529 }
530
531 #[test]
532 fn exclude_does_not_match_weekday() {
533 let exclude = Exclude::Weekends;
534 let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).unwrap();
536 assert!(!exclude.matches(timestamp));
537 }
538
539 #[test]
540 fn exclude_matches_swedish_new_year() {
541 let exclude = Exclude::Holidays(Country::SE);
542 let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
544 assert!(exclude.matches(timestamp));
545 }
546
547 #[test]
548 fn exclude_does_not_match_non_holiday() {
549 let exclude = Exclude::Holidays(Country::SE);
550 let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 2, 12, 0, 0).unwrap();
552 assert!(!exclude.matches(timestamp));
553 }
554
555 #[test]
556 fn cost_period_matches_with_single_include() {
557 let period = CostPeriod::builder()
558 .load(LoadType::High)
559 .fixed_cost(10, 0)
560 .hours(6, 22)
561 .build();
562
563 let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
564 let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
565
566 assert!(period.matches(timestamp_match));
567 assert!(!period.matches(timestamp_no_match));
568 }
569
570 #[test]
571 fn cost_period_matches_with_multiple_includes() {
572 let period = CostPeriod::builder()
573 .load(LoadType::High)
574 .fixed_cost(10, 0)
575 .hours(6, 22)
576 .months(Month::November, Month::March)
577 .build();
578
579 let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
581 let timestamp_wrong_hours = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
583 let timestamp_wrong_months = Stockholm.with_ymd_and_hms(2025, 7, 15, 14, 0, 0).unwrap();
585
586 assert!(period.matches(timestamp_match));
587 assert!(!period.matches(timestamp_wrong_hours));
588 assert!(!period.matches(timestamp_wrong_months));
589 }
590
591 #[test]
592 fn cost_period_matches_with_exclude_weekends() {
593 let period = CostPeriod::builder()
594 .load(LoadType::High)
595 .fixed_cost(10, 0)
596 .hours(6, 22)
597 .exclude_weekends()
598 .build();
599
600 println!("Excludes: {:?}", period.exclude_period_types());
601 println!("Includes: {:?}", period.include_period_types());
602
603 let timestamp_weekday = Stockholm.with_ymd_and_hms(2025, 1, 6, 14, 0, 0).unwrap();
605 let timestamp_saturday = Stockholm.with_ymd_and_hms(2025, 1, 4, 14, 0, 0).unwrap();
607
608 assert!(period.matches(timestamp_weekday));
609 assert!(!period.matches(timestamp_saturday));
610 }
611
612 #[test]
613 fn cost_period_matches_with_exclude_holidays() {
614 let period = CostPeriod::builder()
615 .load(LoadType::High)
616 .fixed_cost(10, 0)
617 .hours(6, 22)
618 .exclude_holidays(Country::SE)
619 .build();
620
621 let timestamp_regular = Stockholm.with_ymd_and_hms(2025, 1, 2, 14, 0, 0).unwrap();
623 let timestamp_holiday = Stockholm.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap();
625
626 assert!(period.matches(timestamp_regular));
627 assert!(!period.matches(timestamp_holiday));
628 }
629
630 #[test]
631 fn cost_period_matches_complex_scenario() {
632 let period = CostPeriod::builder()
634 .load(LoadType::High)
635 .fixed_cost(10, 0)
636 .months(Month::November, Month::March)
637 .hours(6, 22)
638 .exclude_weekends()
639 .exclude_holidays(Country::SE)
640 .build();
641
642 let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
644
645 let timestamp_wrong_hours = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
647
648 let timestamp_weekend = Stockholm.with_ymd_and_hms(2025, 1, 4, 14, 0, 0).unwrap();
650
651 let timestamp_holiday = Stockholm.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap();
653
654 let timestamp_summer = Stockholm.with_ymd_and_hms(2025, 7, 15, 14, 0, 0).unwrap();
656
657 assert!(period.matches(timestamp_match));
658 assert!(!period.matches(timestamp_wrong_hours));
659 assert!(!period.matches(timestamp_weekend));
660 assert!(!period.matches(timestamp_holiday));
661 assert!(!period.matches(timestamp_summer));
662 }
663
664 #[test]
665 fn cost_period_matches_base_load() {
666 let period = CostPeriod::builder()
668 .load(LoadType::Base)
669 .fixed_cost(5, 0)
670 .build();
671
672 let timestamp1 = Stockholm.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
674 let timestamp2 = Stockholm.with_ymd_and_hms(2025, 7, 15, 23, 59, 59).unwrap();
675 let timestamp3 = Stockholm.with_ymd_and_hms(2025, 1, 4, 12, 0, 0).unwrap();
676
677 assert!(period.matches(timestamp1));
678 assert!(period.matches(timestamp2));
679 assert!(period.matches(timestamp3));
680 }
681}