1#[cfg(test)]
4pub mod test;
5
6#[cfg(test)]
7mod test_normalize_periods;
8
9#[cfg(test)]
10mod test_periods;
11
12#[cfg(test)]
13mod test_real_world;
14
15#[cfg(test)]
16mod test_validate_cdr;
17
18#[cfg(test)]
19mod test_current_and_power_restrictions;
20
21#[cfg(test)]
22mod test_reservation_restriction;
23
24#[cfg(test)]
25mod test_min_max_price;
26
27#[cfg(test)]
28mod test_warning_path_map;
29
30mod tariff;
31pub(crate) mod v211;
32pub(crate) mod v221;
33
34use std::{collections::BTreeMap, fmt, ops::Range};
35
36use chrono::{DateTime, Datelike as _, TimeDelta, Utc};
37use chrono_tz::Tz;
38use rust_decimal::Decimal;
39use tariff::Tariff;
40use tracing::{debug, instrument, trace};
41
42use crate::{
43 country, currency, datetime,
44 duration::{self, AsHms as _, Hms},
45 from_warning_all, json,
46 money::{self, VatOrigin},
47 number::{self, RoundDecimal as _},
48 string,
49 warning::{
50 self, GatherDeferredWarnings as _, GatherWarnings as _, IntoCaveat as _,
51 IntoCaveatDeferred as _, WithElement as _,
52 },
53 Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, Price, SaturatingAdd as _,
54 SaturatingSub as _, Versioned as _,
55};
56
57pub type Verdict<T> = crate::Verdict<T, Warning>;
58type VerdictDeferred<T> = warning::VerdictDeferred<T, Warning>;
59
60#[derive(Debug)]
65struct PeriodNormalized {
66 consumed: Consumed,
68
69 start_snapshot: TotalsSnapshot,
71
72 end_snapshot: TotalsSnapshot,
74}
75
76#[derive(Clone)]
78#[cfg_attr(test, derive(Default))]
79pub(crate) struct Consumed {
80 pub current_max: Option<Ampere>,
82
83 pub current_min: Option<Ampere>,
85
86 pub duration_charging: Option<TimeDelta>,
88
89 pub duration_idle: Option<TimeDelta>,
91
92 pub energy: Option<Kwh>,
94
95 pub power_max: Option<Kw>,
97
98 pub power_min: Option<Kw>,
100}
101
102impl fmt::Debug for Consumed {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 f.debug_struct("Consumed")
105 .field("current_max", &self.current_max)
106 .field("current_min", &self.current_min)
107 .field(
108 "duration_charging",
109 &self.duration_charging.map(|dt| dt.as_hms()),
110 )
111 .field("duration_idle", &self.duration_idle.map(|dt| dt.as_hms()))
112 .field("energy", &self.energy)
113 .field("power_max", &self.power_max)
114 .field("power_min", &self.power_min)
115 .finish()
116 }
117}
118
119#[derive(Clone)]
121struct TotalsSnapshot {
122 date_time: DateTime<Utc>,
124
125 energy: Kwh,
127
128 local_timezone: Tz,
130
131 duration_charging: TimeDelta,
133
134 duration_total: TimeDelta,
136}
137
138impl fmt::Debug for TotalsSnapshot {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 f.debug_struct("TotalsSnapshot")
141 .field("date_time", &self.date_time)
142 .field("energy", &self.energy)
143 .field("local_timezone", &self.local_timezone)
144 .field("duration_charging", &self.duration_charging.as_hms())
145 .field("duration_total", &self.duration_total.as_hms())
146 .finish()
147 }
148}
149
150impl TotalsSnapshot {
151 fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
153 Self {
154 date_time,
155 energy: Kwh::zero(),
156 local_timezone,
157 duration_charging: TimeDelta::zero(),
158 duration_total: TimeDelta::zero(),
159 }
160 }
161
162 fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
164 let duration = date_time.signed_duration_since(self.date_time);
165
166 let mut next = Self {
167 date_time,
168 energy: self.energy,
169 local_timezone: self.local_timezone,
170 duration_charging: self.duration_charging,
171 duration_total: self.duration_total.saturating_add(duration),
172 };
173
174 if let Some(duration) = consumed.duration_charging {
175 next.duration_charging = next.duration_charging.saturating_add(duration);
176 }
177
178 if let Some(energy) = consumed.energy {
179 next.energy = next.energy.saturating_add(energy);
180 }
181 next
182 }
183
184 fn local_time(&self) -> chrono::NaiveTime {
186 self.date_time.with_timezone(&self.local_timezone).time()
187 }
188
189 fn local_date(&self) -> chrono::NaiveDate {
191 self.date_time
192 .with_timezone(&self.local_timezone)
193 .date_naive()
194 }
195
196 fn local_weekday(&self) -> chrono::Weekday {
198 self.date_time.with_timezone(&self.local_timezone).weekday()
199 }
200}
201
202pub struct Report {
205 pub periods: Vec<PeriodReport>,
207
208 pub tariff_used: TariffOrigin,
210
211 pub tariff_reports: Vec<TariffReport>,
215
216 pub timezone: String,
218
219 pub billed_charging_time: Option<TimeDelta>,
222
223 pub billed_energy: Option<Kwh>,
225
226 pub billed_idle_time: Option<TimeDelta>,
228
229 pub total_charging_time: Option<TimeDelta>,
235
236 pub total_energy: Total<Kwh, Option<Kwh>>,
238
239 pub total_idle_time: Total<Option<TimeDelta>>,
246
247 pub total_time: Total<TimeDelta>,
249
250 pub total_cost: Total<Price, Option<Price>>,
253
254 pub total_energy_cost: Total<Option<Price>>,
256
257 pub total_fixed_cost: Total<Option<Price>>,
260
261 pub total_idle_cost: Total<Option<Price>>,
268
269 pub total_charging_time_cost: Total<Option<Price>>,
274}
275
276impl fmt::Debug for Report {
277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278 f.debug_struct("Report")
279 .field("periods", &self.periods)
280 .field("tariff_used", &self.tariff_used)
281 .field("tariff_reports", &self.tariff_reports)
282 .field("timezone", &self.timezone)
283 .field(
284 "billed_charging_time",
285 &self.billed_charging_time.map(|dt| dt.as_hms()),
286 )
287 .field("billed_energy", &self.billed_energy)
288 .field(
289 "billed_idle_time",
290 &self.billed_idle_time.map(|dt| dt.as_hms()),
291 )
292 .field(
293 "total_charging_time",
294 &self.total_charging_time.map(|dt| dt.as_hms()),
295 )
296 .field("total_energy", &self.total_energy)
297 .field("total_idle_time", &self.total_idle_time)
298 .field("total_time", &self.total_time)
299 .field("total_cost", &self.total_cost)
300 .field("total_energy_cost", &self.total_energy_cost)
301 .field("total_fixed_cost", &self.total_fixed_cost)
302 .field("total_idle_cost", &self.total_idle_cost)
303 .field("total_charging_time_cost", &self.total_charging_time_cost)
304 .finish()
305 }
306}
307
308#[derive(Debug)]
310pub enum Warning {
311 Country(country::Warning),
312 Currency(currency::Warning),
313 DateTime(datetime::Warning),
314 Decode(json::decode::Warning),
315 Duration(duration::Warning),
316
317 CountryShouldBeAlpha2,
321
322 Money(money::Warning),
323
324 NoPeriods,
326
327 NoValidTariff,
337
338 Number(number::Warning),
339
340 PeriodsOutsideStartEndDateTime {
343 cdr_range: Range<DateTime<Utc>>,
344 period_range: PeriodRange,
345 },
346
347 String(string::Warning),
348
349 Tariff(crate::tariff::Warning),
352
353 Rejected,
357}
358
359impl fmt::Display for Warning {
360 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361 match self {
362 Self::Country(warning) => write!(f, "{warning}"),
363 Self::CountryShouldBeAlpha2 => {
364 f.write_str("The `$.country` field should be an alpha-2 country code.")
365 }
366 Self::Currency(warning) => write!(f, "{warning}"),
367 Self::DateTime(warning) => write!(f, "{warning}"),
368 Self::Decode(warning) => write!(f, "{warning}"),
369 Self::Duration(warning) => write!(f, "{warning}"),
370 Self::Money(warning) => write!(f, "{warning}"),
371 Self::NoPeriods => f.write_str("The CDR has no charging periods"),
372 Self::NoValidTariff => {
373 f.write_str("No valid tariff has been found in the list of provided tariffs")
374 }
375 Self::Number(warning) => write!(f, "{warning}"),
376 Self::PeriodsOutsideStartEndDateTime {
377 cdr_range: Range { start, end },
378 period_range,
379 } => {
380 write!(
381 f,
382 "The CDR's charging period time range is not contained within the `start_date_time` \
383 and `end_date_time`; cdr: [start: {start}, end: {end}], period: {period_range}",
384 )
385 }
386 Self::String(warning) => write!(f, "{warning}"),
387 Self::Tariff(warnings) => {
388 write!(f, "Tariff warnings: {warnings:?}")
389 }
390 Self::Rejected => f.write_str(
391 "The schema IR for a CDR object was rejected; see the schema validation warnings.",
392 ),
393 }
394 }
395}
396
397impl crate::Warning for Warning {
398 fn id(&self) -> warning::Id {
399 match self {
400 Self::Country(warning) => warning.id(),
401 Self::CountryShouldBeAlpha2 => warning::Id::from_static("country_should_be_alpha_2"),
402 Self::Currency(warning) => warning.id(),
403 Self::DateTime(warning) => warning.id(),
404 Self::Decode(warning) => warning.id(),
405 Self::Duration(warning) => warning.id(),
406 Self::Money(warning) => warning.id(),
407 Self::NoPeriods => warning::Id::from_static("no_periods"),
408 Self::NoValidTariff => warning::Id::from_static("no_valid_tariff"),
409 Self::Number(warning) => warning.id(),
410 Self::PeriodsOutsideStartEndDateTime { .. } => {
411 warning::Id::from_static("periods_outside_start_end_date_time")
412 }
413 Self::String(warning) => warning.id(),
414 Self::Tariff(warning) => warning.id(),
415 Self::Rejected => warning::Id::from_static("rejected"),
416 }
417 }
418
419 fn is_rejected(&self) -> bool {
420 matches!(self, Self::Rejected)
421 }
422}
423
424impl From<warning::Rejected> for Warning {
425 fn from(_: warning::Rejected) -> Self {
426 Self::Rejected
427 }
428}
429
430from_warning_all!(
431 country::Warning => Warning::Country,
432 currency::Warning => Warning::Currency,
433 datetime::Warning => Warning::DateTime,
434 duration::Warning => Warning::Duration,
435 json::decode::Warning => Warning::Decode,
436 money::Warning => Warning::Money,
437 number::Warning => Warning::Number,
438 string::Warning => Warning::String,
439 crate::tariff::Warning => Warning::Tariff
440);
441
442#[derive(Debug)]
444pub struct TariffReport {
445 pub origin: TariffOrigin,
447
448 pub warnings: BTreeMap<json::Path, Vec<crate::tariff::Warning>>,
452}
453
454#[derive(Clone, Debug)]
456pub struct TariffOrigin {
457 pub index: usize,
459
460 pub id: String,
462
463 pub currency: currency::Code,
465}
466
467#[derive(Debug)]
469pub(crate) struct Period {
470 pub start_date_time: DateTime<Utc>,
472
473 pub consumed: Consumed,
475}
476
477#[derive(Debug)]
479pub struct Dimensions {
480 pub energy: Option<Dimension<Kwh>>,
482
483 pub flat: Dimension<()>,
485
486 pub duration_charging: Option<Dimension<TimeDelta>>,
488
489 pub duration_idle: Option<Dimension<TimeDelta>>,
491}
492
493impl Dimensions {
494 fn new(components: ComponentSet, consumed: &Consumed) -> Self {
496 let ComponentSet {
497 energy: energy_price,
498 flat: flat_price,
499 duration_charging: duration_charging_price,
500 duration_idle: duration_idle_price,
501 } = components;
502
503 let Consumed {
504 duration_charging,
505 duration_idle,
506 energy,
507 current_max: _,
508 current_min: _,
509 power_max: _,
510 power_min: _,
511 } = consumed;
512
513 Self {
514 energy: (*energy).map(|e| Dimension {
515 price: energy_price,
516 volume: e,
517 billed_volume: e,
518 }),
519 flat: Dimension {
520 price: flat_price,
521 volume: (),
522 billed_volume: (),
523 },
524 duration_charging: (*duration_charging).map(|dc| Dimension {
525 price: duration_charging_price,
526 volume: dc,
527 billed_volume: dc,
528 }),
529 duration_idle: (*duration_idle).map(|di| Dimension {
530 price: duration_idle_price,
531 volume: di,
532 billed_volume: di,
533 }),
534 }
535 }
536}
537
538#[derive(Debug)]
539pub struct Dimension<V> {
541 pub price: Option<Component>,
545
546 pub volume: V,
548
549 pub billed_volume: V,
557}
558
559impl<V: Cost> Dimension<V> {
560 pub fn cost(&self) -> Option<Price> {
562 let Some(price_component) = &self.price else {
563 return None;
564 };
565
566 let excl_vat = self.billed_volume.cost(price_component.price);
567
568 let incl_vat = match price_component.vat {
569 VatOrigin::Provided(vat) => Some(excl_vat.apply_vat(vat)),
570 VatOrigin::NotProvided => Some(excl_vat),
571 VatOrigin::Unknown => None,
572 };
573
574 Some(Price { excl_vat, incl_vat })
575 }
576}
577
578#[derive(Debug)]
583pub struct ComponentSet {
584 pub energy: Option<Component>,
586
587 pub flat: Option<Component>,
589
590 pub duration_charging: Option<Component>,
592
593 pub duration_idle: Option<Component>,
595}
596
597impl ComponentSet {
598 fn has_all_components(&self) -> bool {
600 let Self {
601 energy,
602 flat,
603 duration_charging,
604 duration_idle,
605 } = self;
606
607 flat.is_some() && energy.is_some() && duration_idle.is_some() && duration_charging.is_some()
608 }
609}
610
611#[derive(Clone, Debug)]
616pub struct Component {
617 price: Money,
619
620 vat: VatOrigin,
623
624 step_size: u64,
632}
633
634impl Component {
635 fn new(component: &crate::tariff::v221::PriceComponent) -> Self {
637 let crate::tariff::v221::PriceComponent {
638 price,
639 vat,
640 step_size,
641 dimension_type: _,
642 } = component;
643
644 Self {
645 price: *price,
646 vat: *vat,
647 step_size: *step_size,
648 }
649 }
650
651 pub fn price(&self) -> Money {
653 self.price
654 }
655}
656
657#[derive(Debug)]
670pub struct Total<TCdr, TCalc = TCdr> {
671 pub cdr: TCdr,
673
674 pub calculated: TCalc,
676}
677
678#[derive(Debug)]
680pub enum PeriodRange {
681 Many(Range<DateTime<Utc>>),
684
685 Single(DateTime<Utc>),
687}
688
689impl fmt::Display for PeriodRange {
690 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691 match self {
692 PeriodRange::Many(Range { start, end }) => write!(f, "[start: {start}, end: {end}]"),
693 PeriodRange::Single(date_time) => write!(f, "{date_time}"),
694 }
695 }
696}
697
698#[derive(Debug)]
702pub enum TariffSource<'buf> {
703 UseCdr,
705
706 Override(Vec<crate::tariff::Versioned<'buf>>),
708}
709
710impl<'buf> TariffSource<'buf> {
711 pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
713 Self::Override(vec![tariff])
714 }
715}
716
717#[instrument(skip_all)]
721pub(super) fn cdr(
722 cdr_elem: &crate::cdr::Versioned<'_>,
723 tariff_source: TariffSource<'_>,
724 timezone: Tz,
725) -> Verdict<Report> {
726 let cdr = cdr_elem.to_v221()?;
727
728 match tariff_source {
729 TariffSource::UseCdr => {
730 debug!("Using tariffs from CDR");
731 let tariffs = cdr_elem.tariffs_to_v221()?.ignore_warnings();
732
733 Ok(price_v221_cdr_with_tariffs(
734 cdr_elem, cdr, tariffs, timezone,
735 )?)
736 }
737 TariffSource::Override(tariffs) => {
738 debug!("Using override tariffs");
739 let tariffs = tariffs
740 .iter()
741 .map(crate::tariff::Versioned::to_v221)
742 .collect::<Result<Vec<_>, _>>()?;
743
744 Ok(price_v221_cdr_with_tariffs(
745 cdr_elem, cdr, tariffs, timezone,
746 )?)
747 }
748 }
749}
750
751fn price_v221_cdr_with_tariffs(
758 cdr_elem: &crate::cdr::Versioned<'_>,
759 cdr: Caveat<v221::Cdr, Warning>,
760 tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
761 timezone: Tz,
762) -> Verdict<Report> {
763 debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
764 let (cdr, mut warnings) = cdr.into_parts();
765 let v221::Cdr {
766 start_date_time,
767 end_date_time,
768 charging_periods,
769 totals: cdr_totals,
770 } = cdr;
771
772 let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
777 .into_iter()
778 .enumerate()
779 .map(|(index, tariff)| {
780 let (tariff, warnings) = tariff.into_parts();
781 (
782 TariffReport {
783 origin: TariffOrigin {
784 index,
785 id: tariff.id.to_string(),
786 currency: tariff.currency,
787 },
788 warnings: warnings.into_path_map(),
789 },
790 tariff,
791 )
792 })
793 .unzip();
794
795 debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
796
797 let tariffs_normalized = tariff::normalize_all(&tariffs);
798 let Some((tariff_index, tariff)) =
799 tariff::find_first_active(tariffs_normalized, start_date_time)
800 else {
801 return warnings.bail(cdr_elem.as_element(), Warning::NoValidTariff);
802 };
803
804 debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
805 debug!(%timezone, "Found timezone");
806
807 let periods = charging_periods.into_iter().map(Period::from).collect();
809
810 let periods = normalize_periods(periods, end_date_time, timezone);
811 let price_cdr_report = price_periods(&periods, &tariff)
812 .with_element(cdr_elem.as_element())?
813 .gather_warnings_into(&mut warnings);
814
815 if tariff.has_reservation_elements() {
816 warnings.insert(
817 cdr_elem.as_element(),
818 Warning::Tariff(crate::tariff::Warning::ReservationElementSkipped),
819 );
820 }
821
822 let mut report = generate_report(
823 &cdr_totals,
824 timezone,
825 tariff_reports,
826 price_cdr_report,
827 TariffOrigin {
828 index: tariff_index,
829 id: tariff.id().to_owned(),
830 currency: tariff.currency(),
831 },
832 );
833
834 if let Some(total_cost) = report.total_cost.calculated.as_mut() {
835 if let Some(min_price) = tariff.min_price() {
836 if *total_cost < min_price {
837 *total_cost = min_price;
838 warnings.insert(
839 cdr_elem.as_element(),
840 crate::tariff::Warning::TotalCostClampedToMin.into(),
841 );
842 }
843 }
844
845 if let Some(max_price) = tariff.max_price() {
846 if *total_cost > max_price {
847 *total_cost = max_price;
848 warnings.insert(
849 cdr_elem.as_element(),
850 crate::tariff::Warning::TotalCostClampedToMax.into(),
851 );
852 }
853 }
854 }
855
856 Ok(report.into_caveat(warnings))
857}
858
859pub(crate) fn periods(
861 end_date_time: DateTime<Utc>,
862 timezone: Tz,
863 tariff_elem: &crate::tariff::v221::Tariff<'_>,
864 mut periods: Vec<Period>,
865) -> VerdictDeferred<PeriodsReport> {
866 periods.sort_by_key(|p| p.start_date_time);
869 let tariff = Tariff::from_v221(tariff_elem);
870 let periods = normalize_periods(periods, end_date_time, timezone);
871 price_periods(&periods, &tariff)
872}
873
874fn normalize_periods(
875 periods: Vec<Period>,
876 end_date_time: DateTime<Utc>,
877 local_timezone: Tz,
878) -> Vec<PeriodNormalized> {
879 debug!("Normalizing CDR periods");
880
881 let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
883
884 let end_dates = {
886 let mut end_dates = periods
887 .iter()
888 .skip(1)
889 .map(|p| p.start_date_time)
890 .collect::<Vec<_>>();
891
892 end_dates.push(end_date_time);
894 end_dates
895 };
896
897 let periods = periods
898 .into_iter()
899 .zip(end_dates)
900 .enumerate()
901 .map(|(index, (period, end_date_time))| {
902 trace!(index, "processing\n{period:#?}");
903 let Period {
904 start_date_time,
905 consumed,
906 } = period;
907
908 let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
909 let start_snapshot = prev_end_snapshot;
910 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
911
912 let period = PeriodNormalized {
913 consumed,
914 start_snapshot,
915 end_snapshot,
916 };
917 trace!("Adding new period based on the last added\n{period:#?}");
918 period
919 } else {
920 let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
921 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
922
923 let period = PeriodNormalized {
924 consumed,
925 start_snapshot,
926 end_snapshot,
927 };
928 trace!("Adding new period\n{period:#?}");
929 period
930 };
931
932 previous_end_snapshot.replace(period.end_snapshot.clone());
933 period
934 })
935 .collect::<Vec<_>>();
936
937 periods
938}
939
940fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
942 debug!(count = periods.len(), "Pricing CDR periods");
943
944 if tracing::enabled!(tracing::Level::TRACE) {
945 trace!("# CDR period list:");
946 for period in periods {
947 trace!("{period:#?}");
948 }
949 }
950
951 let period_totals = period_totals(periods, tariff);
952 let (billed, mut warnings) = period_totals.calculate_billed()?.into_parts();
953
954 if tariff.has_reservation_elements() {
955 warnings.insert(Warning::Tariff(
956 crate::tariff::Warning::ReservationElementSkipped,
957 ));
958 }
959
960 let (billable, periods, totals) = billed;
961 let total_costs = total_costs(&periods, tariff);
962 let report = PeriodsReport {
963 billable,
964 periods,
965 totals,
966 total_costs,
967 };
968
969 Ok(report.into_caveat_deferred(warnings))
970}
971
972pub(crate) struct PeriodsReport {
974 pub billable: Billable,
976
977 pub periods: Vec<PeriodReport>,
979
980 pub totals: Totals,
982
983 pub total_costs: TotalCosts,
985}
986
987#[derive(Debug)]
993pub struct PeriodReport {
994 pub start_date_time: DateTime<Utc>,
996
997 pub end_date_time: DateTime<Utc>,
999
1000 pub dimensions: Dimensions,
1002}
1003
1004impl PeriodReport {
1005 pub fn cost(&self) -> Option<Price> {
1007 [
1008 self.dimensions
1009 .duration_charging
1010 .as_ref()
1011 .and_then(Dimension::cost),
1012 self.dimensions
1013 .duration_idle
1014 .as_ref()
1015 .and_then(Dimension::cost),
1016 self.dimensions.flat.cost(),
1017 self.dimensions.energy.as_ref().and_then(Dimension::cost),
1018 ]
1019 .into_iter()
1020 .fold(None, |accum, next| {
1021 if accum.is_none() && next.is_none() {
1022 None
1023 } else {
1024 Some(
1025 accum
1026 .unwrap_or_default()
1027 .saturating_add(next.unwrap_or_default()),
1028 )
1029 }
1030 })
1031 }
1032}
1033
1034#[derive(Debug)]
1039struct PeriodReportScratch {
1040 start_date_time: DateTime<Utc>,
1041 end_date_time: DateTime<Utc>,
1042 dimensions: Dimensions,
1043 step_size_duration_charging: Option<Component>,
1044 step_size_duration_idle: Option<Component>,
1045 step_size_energy: Option<Component>,
1046}
1047
1048impl From<PeriodReportScratch> for PeriodReport {
1049 fn from(scratch: PeriodReportScratch) -> Self {
1050 Self {
1051 start_date_time: scratch.start_date_time,
1052 end_date_time: scratch.end_date_time,
1053 dimensions: scratch.dimensions,
1054 }
1055 }
1056}
1057
1058#[derive(Debug)]
1060struct PeriodTotals {
1061 periods: Vec<PeriodReportScratch>,
1063
1064 totals: Totals,
1066}
1067
1068#[derive(Debug, Default)]
1070pub(crate) struct Totals {
1071 pub energy: Option<Kwh>,
1073
1074 pub duration_charging: Option<TimeDelta>,
1078
1079 pub duration_idle: Option<TimeDelta>,
1083}
1084
1085impl PeriodTotals {
1086 fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
1088 let mut warnings = warning::SetDeferred::new();
1089 let Self {
1090 mut periods,
1091 totals,
1092 } = self;
1093
1094 let billable =
1095 apply_step_sizes(&mut periods, &totals)?.gather_deferred_warnings_into(&mut warnings);
1096
1097 let periods = periods.into_iter().map(PeriodReport::from).collect();
1098
1099 Ok((billable, periods, totals).into_caveat_deferred(warnings))
1100 }
1101}
1102
1103#[derive(Debug)]
1105pub(crate) struct Billable {
1106 duration_charging: Option<TimeDelta>,
1108
1109 duration_idle: Option<TimeDelta>,
1111
1112 energy: Option<Kwh>,
1114}
1115
1116fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1119 let mut has_flat_fee = false;
1120 let mut totals = Totals::default();
1121
1122 debug!(
1123 tariff_id = tariff.id(),
1124 period_count = periods.len(),
1125 "Accumulating dimension totals for each period"
1126 );
1127
1128 let periods = periods
1129 .iter()
1130 .enumerate()
1131 .map(|(index, period)| {
1132 let mut component_set = tariff.active_components(period);
1133 trace!(
1134 index,
1135 "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1136 );
1137
1138 if component_set.flat.is_some() {
1139 if has_flat_fee {
1140 component_set.flat = None;
1141 } else {
1142 has_flat_fee = true;
1143 }
1144 }
1145
1146 let step_size_duration_charging = if period.consumed.duration_charging.is_some() {
1148 component_set.duration_charging.clone()
1149 } else {
1150 None
1151 };
1152 let step_size_duration_idle = if period.consumed.duration_idle.is_some() {
1153 component_set.duration_idle.clone()
1154 } else {
1155 None
1156 };
1157 let step_size_energy = if period.consumed.energy.is_some() {
1158 component_set.energy.clone()
1159 } else {
1160 None
1161 };
1162
1163 let dimensions = Dimensions::new(component_set, &period.consumed);
1164
1165 trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1166
1167 if let Some(dim) = &dimensions.duration_charging {
1168 let acc = totals.duration_charging.get_or_insert_default();
1169 *acc = acc.saturating_add(dim.volume);
1170 }
1171
1172 if let Some(dim) = &dimensions.energy {
1173 let acc = totals.energy.get_or_insert_default();
1174 *acc = acc.saturating_add(dim.volume);
1175 }
1176
1177 if let Some(dim) = &dimensions.duration_idle {
1178 let acc = totals.duration_idle.get_or_insert_default();
1179 *acc = acc.saturating_add(dim.volume);
1180 }
1181
1182 trace!(period_index = index, ?totals, "Update totals");
1183
1184 PeriodReportScratch {
1185 start_date_time: period.start_snapshot.date_time,
1186 end_date_time: period.end_snapshot.date_time,
1187 dimensions,
1188 step_size_duration_charging,
1189 step_size_duration_idle,
1190 step_size_energy,
1191 }
1192 })
1193 .collect::<Vec<_>>();
1194
1195 PeriodTotals { periods, totals }
1196}
1197
1198#[derive(Debug, Default)]
1200pub(crate) struct TotalCosts {
1201 pub energy: Option<Price>,
1203
1204 pub fixed: Option<Price>,
1206
1207 pub duration_charging: Option<Price>,
1209
1210 pub duration_idle: Option<Price>,
1212}
1213
1214impl TotalCosts {
1215 pub(crate) fn total(&self) -> Option<Price> {
1219 let Self {
1220 energy,
1221 fixed,
1222 duration_charging,
1223 duration_idle,
1224 } = self;
1225 debug!(
1226 energy = %DisplayOption(*energy),
1227 fixed = %DisplayOption(*fixed),
1228 duration_charging = %DisplayOption(*duration_charging),
1229 duration_idle = %DisplayOption(*duration_idle),
1230 "Calculating total costs."
1231 );
1232 [energy, fixed, duration_charging, duration_idle]
1233 .into_iter()
1234 .fold(None, |accum: Option<Price>, next| match (accum, next) {
1235 (None, None) => None,
1236 _ => Some(
1237 accum
1238 .unwrap_or_default()
1239 .saturating_add(next.unwrap_or_default()),
1240 ),
1241 })
1242 }
1243}
1244
1245fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1247 let mut total_costs = TotalCosts::default();
1248
1249 debug!(
1250 tariff_id = tariff.id(),
1251 period_count = periods.len(),
1252 "Accumulating dimension costs for each period"
1253 );
1254 for (index, period) in periods.iter().enumerate() {
1255 let dimensions = &period.dimensions;
1256
1257 trace!(period_index = index, "Processing period");
1258
1259 let energy_cost = dimensions.energy.as_ref().and_then(Dimension::cost);
1260 let fixed_cost = dimensions.flat.cost();
1261 let duration_charging_cost = dimensions
1262 .duration_charging
1263 .as_ref()
1264 .and_then(Dimension::cost);
1265 let duration_idle_cost = dimensions.duration_idle.as_ref().and_then(Dimension::cost);
1266
1267 trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1268 trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1269 trace!(?total_costs.duration_idle, ?duration_idle_cost, "Idle cost");
1270 trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1271
1272 total_costs.energy = match (total_costs.energy, energy_cost) {
1273 (None, None) => None,
1274 (total, period) => Some(
1275 total
1276 .unwrap_or_default()
1277 .saturating_add(period.unwrap_or_default()),
1278 ),
1279 };
1280
1281 total_costs.duration_charging =
1282 match (total_costs.duration_charging, duration_charging_cost) {
1283 (None, None) => None,
1284 (total, period) => Some(
1285 total
1286 .unwrap_or_default()
1287 .saturating_add(period.unwrap_or_default()),
1288 ),
1289 };
1290
1291 total_costs.duration_idle = match (total_costs.duration_idle, duration_idle_cost) {
1292 (None, None) => None,
1293 (total, period) => Some(
1294 total
1295 .unwrap_or_default()
1296 .saturating_add(period.unwrap_or_default()),
1297 ),
1298 };
1299
1300 total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1301 (None, None) => None,
1302 (total, period) => Some(
1303 total
1304 .unwrap_or_default()
1305 .saturating_add(period.unwrap_or_default()),
1306 ),
1307 };
1308
1309 trace!(period_index = index, ?total_costs, "Update totals");
1310 }
1311
1312 total_costs
1313}
1314
1315fn generate_report(
1316 cdr_totals: &v221::cdr::Totals,
1317 timezone: Tz,
1318 tariff_reports: Vec<TariffReport>,
1319 price_periods_report: PeriodsReport,
1320 tariff_used: TariffOrigin,
1321) -> Report {
1322 let PeriodsReport {
1323 billable,
1324 periods,
1325 totals,
1326 total_costs,
1327 } = price_periods_report;
1328 trace!("Update billed totals {billable:#?}");
1329
1330 let total_cost = total_costs.total();
1331
1332 debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1333
1334 let total_time = {
1335 debug!(
1336 period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1337 period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1338 "Calculating `total_time`"
1339 );
1340
1341 periods
1342 .first()
1343 .zip(periods.last())
1344 .map(|(first, last)| {
1345 last.end_date_time
1346 .signed_duration_since(first.start_date_time)
1347 })
1348 .unwrap_or_default()
1349 };
1350 debug!(total_time = %Hms(total_time));
1351
1352 let report = Report {
1353 periods,
1354 tariff_used,
1355 timezone: timezone.to_string(),
1356 billed_idle_time: billable.duration_idle,
1357 billed_energy: billable.energy.round_to_ocpi_scale(),
1358 billed_charging_time: billable.duration_charging,
1359 tariff_reports,
1360 total_charging_time: totals.duration_charging,
1361 total_cost: Total {
1362 cdr: cdr_totals.cost.round_to_ocpi_scale(),
1363 calculated: total_cost.round_to_ocpi_scale(),
1364 },
1365 total_charging_time_cost: Total {
1366 cdr: cdr_totals.duration_charging_cost.round_to_ocpi_scale(),
1367 calculated: total_costs.duration_charging.round_to_ocpi_scale(),
1368 },
1369 total_time: Total {
1370 cdr: cdr_totals.duration_charging,
1371 calculated: total_time,
1372 },
1373 total_idle_cost: Total {
1374 cdr: cdr_totals.duration_idle_cost.round_to_ocpi_scale(),
1375 calculated: total_costs.duration_idle.round_to_ocpi_scale(),
1376 },
1377 total_idle_time: Total {
1378 cdr: cdr_totals.duration_idle,
1379 calculated: totals.duration_idle,
1380 },
1381 total_energy_cost: Total {
1382 cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
1383 calculated: total_costs.energy.round_to_ocpi_scale(),
1384 },
1385 total_energy: Total {
1386 cdr: cdr_totals.energy.round_to_ocpi_scale(),
1387 calculated: totals.energy.round_to_ocpi_scale(),
1388 },
1389 total_fixed_cost: Total {
1390 cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
1391 calculated: total_costs.fixed.round_to_ocpi_scale(),
1392 },
1393 };
1394
1395 trace!("{report:#?}");
1396
1397 report
1398}
1399
1400fn apply_step_sizes(
1403 periods: &mut [PeriodReportScratch],
1404 totals: &Totals,
1405) -> VerdictDeferred<Billable> {
1406 let mut warnings = warning::SetDeferred::new();
1407
1408 let has_idle_step_size = periods.iter().any(|p| p.step_size_duration_idle.is_some());
1409
1410 let duration_charging = if let Some(total) = totals.duration_charging {
1411 let mut result = Some(total);
1412 for period in periods.iter_mut().rev() {
1413 let Some(step) = period
1414 .step_size_duration_charging
1415 .as_ref()
1416 .map(|c| c.step_size)
1417 else {
1418 continue;
1419 };
1420 if has_idle_step_size {
1421 result = Some(total);
1422 } else if let Some(dim) = period.dimensions.duration_charging.as_mut() {
1423 let dt = duration_step_size(total, &mut dim.billed_volume, step)?
1424 .gather_deferred_warnings_into(&mut warnings);
1425 result = Some(dt);
1426 }
1427 break;
1428 }
1429 result
1430 } else {
1431 None
1432 };
1433
1434 let duration_idle = if let Some(total) = totals.duration_idle {
1435 let mut result = Some(total);
1436 for period in periods.iter_mut().rev() {
1437 let Some(step) = period.step_size_duration_idle.as_ref().map(|c| c.step_size) else {
1438 continue;
1439 };
1440 if let Some(dim) = period.dimensions.duration_idle.as_mut() {
1441 let dt = duration_step_size(total, &mut dim.billed_volume, step)?
1442 .gather_deferred_warnings_into(&mut warnings);
1443 result = Some(dt);
1444 }
1445 break;
1446 }
1447 result
1448 } else {
1449 None
1450 };
1451
1452 let energy = if let Some(total) = totals.energy {
1453 let mut result = Some(total);
1454 for period in periods.iter_mut().rev() {
1455 let Some(step) = period.step_size_energy.as_ref().map(|c| c.step_size) else {
1456 continue;
1457 };
1458 if step == 0 {
1459 result = Some(total);
1460 } else {
1461 let step_dec = Decimal::from(step);
1462 if let Some(dim) = period.dimensions.energy.as_mut() {
1463 let Some(watt_hours) = total.watt_hours().checked_div(step_dec) else {
1464 return warnings.bail(duration::Warning::Overflow.into());
1465 };
1466 let total_billed_volume =
1467 Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_dec));
1468 let period_delta_volume = total_billed_volume.saturating_sub(total);
1469 dim.billed_volume = dim.billed_volume.saturating_add(period_delta_volume);
1470 result = Some(total_billed_volume);
1471 }
1472 }
1473 break;
1474 }
1475 result
1476 } else {
1477 None
1478 };
1479
1480 Ok(Billable {
1481 duration_charging,
1482 duration_idle,
1483 energy,
1484 }
1485 .into_caveat_deferred(warnings))
1486}
1487
1488fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1490 Decimal::from(delta.num_milliseconds())
1491 .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1492 .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1493}
1494
1495fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1497 let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1498 let Ok(millis) = i64::try_from(millis) else {
1499 return Err(warning::ErrorSetDeferred::with_warn(
1500 duration::Warning::Overflow.into(),
1501 ));
1502 };
1503 let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1504 return Err(warning::ErrorSetDeferred::with_warn(
1505 duration::Warning::Overflow.into(),
1506 ));
1507 };
1508 Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1509}
1510
1511fn duration_step_size(
1513 total_volume: TimeDelta,
1514 period_billed_volume: &mut TimeDelta,
1515 step_size: u64,
1516) -> VerdictDeferred<TimeDelta> {
1517 if step_size == 0 {
1518 return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1519 }
1520
1521 let total_seconds = delta_as_seconds_dec(total_volume);
1522 let step_size = Decimal::from(step_size);
1523
1524 let Some(x) = total_seconds.checked_div(step_size) else {
1525 return Err(warning::ErrorSetDeferred::with_warn(
1526 duration::Warning::Overflow.into(),
1527 ));
1528 };
1529 let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1530
1531 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1532 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1533
1534 Ok(total_billed_volume)
1535}