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