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_min_max_price;
23
24#[cfg(test)]
25mod test_warning_path_map;
26
27mod tariff;
28mod v211;
29mod v221;
30
31use std::{borrow::Cow, collections::BTreeMap, fmt, ops::Range};
32
33use chrono::{DateTime, Datelike as _, TimeDelta, Utc};
34use chrono_tz::Tz;
35use rust_decimal::Decimal;
36use tracing::{debug, instrument, trace};
37
38use crate::{
39 country, currency, datetime,
40 duration::{self, AsHms as _, Hms},
41 enumeration, from_warning_all,
42 json::{self, FromJson as _},
43 money::{self, VatOrigin},
44 number::{self, RoundDecimal as _},
45 string,
46 warning::{
47 self, GatherDeferredWarnings as _, GatherWarnings as _, IntoCaveat as _,
48 IntoCaveatDeferred as _, VerdictExt as _, WithElement as _,
49 },
50 Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, ParseError, Price, SaturatingAdd as _,
51 SaturatingSub as _, Version, Versioned as _,
52};
53
54use tariff::Tariff;
55
56pub type Verdict<T> = crate::Verdict<T, Warning>;
57type VerdictDeferred<T> = warning::VerdictDeferred<T, Warning>;
58
59#[derive(Debug)]
64struct PeriodNormalized {
65 consumed: Consumed,
67
68 start_snapshot: TotalsSnapshot,
70
71 end_snapshot: TotalsSnapshot,
73}
74
75#[derive(Clone)]
77#[cfg_attr(test, derive(Default))]
78pub(crate) struct Consumed {
79 pub current_max: Option<Ampere>,
81
82 pub current_min: Option<Ampere>,
84
85 pub duration_charging: Option<TimeDelta>,
87
88 pub duration_idle: Option<TimeDelta>,
90
91 pub energy: Option<Kwh>,
93
94 pub power_max: Option<Kw>,
96
97 pub power_min: Option<Kw>,
99}
100
101impl fmt::Debug for Consumed {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.debug_struct("Consumed")
104 .field("current_max", &self.current_max)
105 .field("current_min", &self.current_min)
106 .field(
107 "duration_charging",
108 &self.duration_charging.map(|dt| dt.as_hms()),
109 )
110 .field("duration_idle", &self.duration_idle.map(|dt| dt.as_hms()))
111 .field("energy", &self.energy)
112 .field("power_max", &self.power_max)
113 .field("power_min", &self.power_min)
114 .finish()
115 }
116}
117
118#[derive(Clone)]
120struct TotalsSnapshot {
121 date_time: DateTime<Utc>,
123
124 energy: Kwh,
126
127 local_timezone: Tz,
129
130 duration_charging: TimeDelta,
132
133 duration_total: TimeDelta,
135}
136
137impl fmt::Debug for TotalsSnapshot {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 f.debug_struct("TotalsSnapshot")
140 .field("date_time", &self.date_time)
141 .field("energy", &self.energy)
142 .field("local_timezone", &self.local_timezone)
143 .field("duration_charging", &self.duration_charging.as_hms())
144 .field("duration_total", &self.duration_total.as_hms())
145 .finish()
146 }
147}
148
149impl TotalsSnapshot {
150 fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
152 Self {
153 date_time,
154 energy: Kwh::zero(),
155 local_timezone,
156 duration_charging: TimeDelta::zero(),
157 duration_total: TimeDelta::zero(),
158 }
159 }
160
161 fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
163 let duration = date_time.signed_duration_since(self.date_time);
164
165 let mut next = Self {
166 date_time,
167 energy: self.energy,
168 local_timezone: self.local_timezone,
169 duration_charging: self.duration_charging,
170 duration_total: self.duration_total.saturating_add(duration),
171 };
172
173 if let Some(duration) = consumed.duration_charging {
174 next.duration_charging = next.duration_charging.saturating_add(duration);
175 }
176
177 if let Some(energy) = consumed.energy {
178 next.energy = next.energy.saturating_add(energy);
179 }
180 next
181 }
182
183 fn local_time(&self) -> chrono::NaiveTime {
185 self.date_time.with_timezone(&self.local_timezone).time()
186 }
187
188 fn local_date(&self) -> chrono::NaiveDate {
190 self.date_time
191 .with_timezone(&self.local_timezone)
192 .date_naive()
193 }
194
195 fn local_weekday(&self) -> chrono::Weekday {
197 self.date_time.with_timezone(&self.local_timezone).weekday()
198 }
199}
200
201pub struct Report {
204 pub periods: Vec<PeriodReport>,
206
207 pub tariff_used: TariffOrigin,
209
210 pub tariff_reports: Vec<TariffReport>,
214
215 pub timezone: String,
217
218 pub billed_charging_time: Option<TimeDelta>,
221
222 pub billed_energy: Option<Kwh>,
224
225 pub billed_idle_time: Option<TimeDelta>,
227
228 pub total_charging_time: Option<TimeDelta>,
234
235 pub total_energy: Total<Kwh, Option<Kwh>>,
237
238 pub total_idle_time: Total<Option<TimeDelta>>,
245
246 pub total_time: Total<TimeDelta>,
248
249 pub total_cost: Total<Price, Option<Price>>,
252
253 pub total_energy_cost: Total<Option<Price>>,
255
256 pub total_fixed_cost: Total<Option<Price>>,
259
260 pub total_idle_cost: Total<Option<Price>>,
267
268 pub total_charging_time_cost: Total<Option<Price>>,
273}
274
275impl fmt::Debug for Report {
276 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277 f.debug_struct("Report")
278 .field("periods", &self.periods)
279 .field("tariff_used", &self.tariff_used)
280 .field("tariff_reports", &self.tariff_reports)
281 .field("timezone", &self.timezone)
282 .field(
283 "billed_charging_time",
284 &self.billed_charging_time.map(|dt| dt.as_hms()),
285 )
286 .field("billed_energy", &self.billed_energy)
287 .field(
288 "billed_idle_time",
289 &self.billed_idle_time.map(|dt| dt.as_hms()),
290 )
291 .field(
292 "total_charging_time",
293 &self.total_charging_time.map(|dt| dt.as_hms()),
294 )
295 .field("total_energy", &self.total_energy)
296 .field("total_idle_time", &self.total_idle_time)
297 .field("total_time", &self.total_time)
298 .field("total_cost", &self.total_cost)
299 .field("total_energy_cost", &self.total_energy_cost)
300 .field("total_fixed_cost", &self.total_fixed_cost)
301 .field("total_idle_cost", &self.total_idle_cost)
302 .field("total_charging_time_cost", &self.total_charging_time_cost)
303 .finish()
304 }
305}
306
307#[derive(Debug)]
309pub enum Warning {
310 Country(country::Warning),
311 Currency(currency::Warning),
312 DateTime(datetime::Warning),
313 Decode(json::decode::Warning),
314 Duration(duration::Warning),
315 Enum(enumeration::Warning),
316
317 CountryShouldBeAlpha2,
321
322 FieldInvalidType {
324 expected_type: json::ValueKind,
326 },
327
328 FieldInvalidValue {
330 value: String,
332
333 message: Cow<'static, str>,
335 },
336
337 FieldRequired {
339 field_name: Cow<'static, str>,
340 },
341
342 Money(money::Warning),
343
344 NoPeriods,
346
347 NoValidTariff,
357
358 Number(number::Warning),
359
360 Parse(ParseError),
362
363 PeriodsOutsideStartEndDateTime {
366 cdr_range: Range<DateTime<Utc>>,
367 period_range: PeriodRange,
368 },
369
370 String(string::Warning),
371
372 Tariff(crate::tariff::Warning),
375}
376
377impl Warning {
378 fn field_invalid_value(
380 value: impl Into<String>,
381 message: impl Into<Cow<'static, str>>,
382 ) -> Self {
383 Warning::FieldInvalidValue {
384 value: value.into(),
385 message: message.into(),
386 }
387 }
388}
389
390impl fmt::Display for Warning {
391 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392 match self {
393 Self::Country(warning) => write!(f, "{warning}"),
394 Self::CountryShouldBeAlpha2 => {
395 f.write_str("The `$.country` field should be an alpha-2 country code.")
396 }
397 Self::Currency(warning) => write!(f, "{warning}"),
398 Self::DateTime(warning) => write!(f, "{warning}"),
399 Self::Decode(warning) => write!(f, "{warning}"),
400 Self::Duration(warning) => write!(f, "{warning}"),
401 Self::Enum(warning) => write!(f, "{warning}"),
402 Self::FieldInvalidType { expected_type } => {
403 write!(f, "Field has invalid type. Expected type `{expected_type}`")
404 }
405 Self::FieldInvalidValue { value, message } => {
406 write!(f, "Field has invalid value `{value}`: {message}")
407 }
408 Self::FieldRequired { field_name } => {
409 write!(f, "Field is required: `{field_name}`")
410 }
411 Self::Money(warning) => write!(f, "{warning}"),
412 Self::NoPeriods => f.write_str("The CDR has no charging periods"),
413 Self::NoValidTariff => {
414 f.write_str("No valid tariff has been found in the list of provided tariffs")
415 }
416 Self::Number(warning) => write!(f, "{warning}"),
417 Self::Parse(err) => {
418 write!(f, "{err}")
419 }
420 Self::PeriodsOutsideStartEndDateTime {
421 cdr_range: Range { start, end },
422 period_range,
423 } => {
424 write!(
425 f,
426 "The CDR's charging period time range is not contained within the `start_date_time` \
427 and `end_date_time`; cdr: [start: {start}, end: {end}], period: {period_range}",
428 )
429 }
430 Self::String(warning) => write!(f, "{warning}"),
431 Self::Tariff(warnings) => {
432 write!(f, "Tariff warnings: {warnings:?}")
433 }
434 }
435 }
436}
437
438impl crate::Warning for Warning {
439 fn id(&self) -> warning::Id {
440 match self {
441 Self::Country(warning) => warning.id(),
442 Self::CountryShouldBeAlpha2 => warning::Id::from_static("country_should_be_alpha_2"),
443 Self::Currency(warning) => warning.id(),
444 Self::DateTime(warning) => warning.id(),
445 Self::Decode(warning) => warning.id(),
446 Self::Duration(warning) => warning.id(),
447 Self::Enum(warning) => warning.id(),
448 Self::FieldInvalidType { expected_type } => {
449 warning::Id::from_string(format!("field_invalid_type({expected_type})"))
450 }
451 Self::FieldInvalidValue { value, .. } => {
452 warning::Id::from_string(format!("field_invalid_value({value})"))
453 }
454 Self::FieldRequired { field_name } => {
455 warning::Id::from_string(format!("field_required({field_name})"))
456 }
457 Self::Money(warning) => warning.id(),
458 Self::NoPeriods => warning::Id::from_static("no_periods"),
459 Self::NoValidTariff => warning::Id::from_static("no_valid_tariff"),
460 Self::Number(warning) => warning.id(),
461 Self::Parse(ParseError { object: _, kind }) => kind.id(),
462 Self::PeriodsOutsideStartEndDateTime { .. } => {
463 warning::Id::from_static("periods_outside_start_end_date_time")
464 }
465 Self::String(warning) => warning.id(),
466 Self::Tariff(warning) => warning.id(),
467 }
468 }
469}
470
471from_warning_all!(
472 country::Warning => Warning::Country,
473 currency::Warning => Warning::Currency,
474 datetime::Warning => Warning::DateTime,
475 duration::Warning => Warning::Duration,
476 enumeration::Warning => Warning::Enum,
477 json::decode::Warning => Warning::Decode,
478 money::Warning => Warning::Money,
479 number::Warning => Warning::Number,
480 string::Warning => Warning::String,
481 crate::tariff::Warning => Warning::Tariff
482);
483
484#[derive(Debug)]
486pub struct TariffReport {
487 pub origin: TariffOrigin,
489
490 pub warnings: BTreeMap<warning::Path, Vec<crate::tariff::Warning>>,
494}
495
496#[derive(Clone, Debug)]
498pub struct TariffOrigin {
499 pub index: usize,
501
502 pub id: String,
504
505 pub currency: currency::Code,
507}
508
509#[derive(Debug)]
511pub(crate) struct Period {
512 pub start_date_time: DateTime<Utc>,
514
515 pub consumed: Consumed,
517}
518
519#[derive(Debug)]
521pub struct Dimensions {
522 pub energy: Option<Dimension<Kwh>>,
524
525 pub flat: Dimension<()>,
527
528 pub duration_charging: Option<Dimension<TimeDelta>>,
530
531 pub duration_idle: Option<Dimension<TimeDelta>>,
533}
534
535impl Dimensions {
536 fn new(components: ComponentSet, consumed: &Consumed) -> Self {
538 let ComponentSet {
539 energy: energy_price,
540 flat: flat_price,
541 duration_charging: duration_charging_price,
542 duration_idle: duration_idle_price,
543 } = components;
544
545 let Consumed {
546 duration_charging,
547 duration_idle,
548 energy,
549 current_max: _,
550 current_min: _,
551 power_max: _,
552 power_min: _,
553 } = consumed;
554
555 Self {
556 energy: (*energy).map(|e| Dimension {
557 price: energy_price,
558 volume: e,
559 billed_volume: e,
560 }),
561 flat: Dimension {
562 price: flat_price,
563 volume: (),
564 billed_volume: (),
565 },
566 duration_charging: (*duration_charging).map(|dc| Dimension {
567 price: duration_charging_price,
568 volume: dc,
569 billed_volume: dc,
570 }),
571 duration_idle: (*duration_idle).map(|di| Dimension {
572 price: duration_idle_price,
573 volume: di,
574 billed_volume: di,
575 }),
576 }
577 }
578}
579
580#[derive(Debug)]
581pub struct Dimension<V> {
583 pub price: Option<Component>,
587
588 pub volume: V,
590
591 pub billed_volume: V,
599}
600
601impl<V: Cost> Dimension<V> {
602 pub fn cost(&self) -> Option<Price> {
604 let Some(price_component) = &self.price else {
605 return None;
606 };
607
608 let excl_vat = self.billed_volume.cost(price_component.price);
609
610 let incl_vat = match price_component.vat {
611 VatOrigin::Provided(vat) => Some(excl_vat.apply_vat(vat)),
612 VatOrigin::NotProvided => Some(excl_vat),
613 VatOrigin::Unknown => None,
614 };
615
616 Some(Price { excl_vat, incl_vat })
617 }
618}
619
620#[derive(Debug)]
625pub struct ComponentSet {
626 pub energy: Option<Component>,
628
629 pub flat: Option<Component>,
631
632 pub duration_charging: Option<Component>,
634
635 pub duration_idle: Option<Component>,
637}
638
639impl ComponentSet {
640 fn has_all_components(&self) -> bool {
642 let Self {
643 energy,
644 flat,
645 duration_charging,
646 duration_idle,
647 } = self;
648
649 flat.is_some() && energy.is_some() && duration_idle.is_some() && duration_charging.is_some()
650 }
651}
652
653#[derive(Clone, Debug)]
658pub struct Component {
659 price: Money,
661
662 vat: VatOrigin,
665
666 step_size: u64,
674}
675
676impl Component {
677 fn new(component: &crate::tariff::v221::PriceComponent) -> Self {
679 let crate::tariff::v221::PriceComponent {
680 price,
681 vat,
682 step_size,
683 dimension_type: _,
684 } = component;
685
686 Self {
687 price: *price,
688 vat: *vat,
689 step_size: *step_size,
690 }
691 }
692
693 pub fn price(&self) -> Money {
695 self.price
696 }
697}
698
699#[derive(Debug)]
712pub struct Total<TCdr, TCalc = TCdr> {
713 pub cdr: TCdr,
715
716 pub calculated: TCalc,
718}
719
720#[derive(Debug)]
722pub enum PeriodRange {
723 Many(Range<DateTime<Utc>>),
726
727 Single(DateTime<Utc>),
729}
730
731impl fmt::Display for PeriodRange {
732 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
733 match self {
734 PeriodRange::Many(Range { start, end }) => write!(f, "[start: {start}, end: {end}]"),
735 PeriodRange::Single(date_time) => write!(f, "{date_time}"),
736 }
737 }
738}
739
740#[derive(Debug)]
744pub enum TariffSource<'buf> {
745 UseCdr,
747
748 Override(Vec<crate::tariff::Versioned<'buf>>),
750}
751
752impl<'buf> TariffSource<'buf> {
753 pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
755 Self::Override(vec![tariff])
756 }
757}
758
759#[instrument(skip_all)]
763pub(super) fn cdr(
764 cdr_elem: &crate::cdr::Versioned<'_>,
765 tariff_source: TariffSource<'_>,
766 timezone: Tz,
767) -> Verdict<Report> {
768 let source_version = cdr_elem.version();
769 let cdr = parse_cdr(cdr_elem)?;
770
771 match tariff_source {
772 TariffSource::UseCdr => {
773 let (v221::cdr::WithTariffs { cdr, tariffs }, warnings) = cdr.into_parts();
774 debug!("Using tariffs from CDR");
775 let tariffs = tariffs
776 .iter()
777 .map(|elem| {
778 match source_version {
780 Version::V221 => crate::tariff::v221::Tariff::from_json(elem),
781 Version::V211 => {
782 let tariff = crate::tariff::v211::Tariff::from_json(elem);
783 tariff.map_caveat(crate::tariff::v221::Tariff::from)
786 }
787 }
788 })
789 .collect::<Result<Vec<_>, _>>()?;
790
791 let cdr = cdr.into_caveat(warnings);
792
793 Ok(price_v221_cdr_with_tariffs(
794 cdr_elem, cdr, tariffs, timezone,
795 )?)
796 }
797 TariffSource::Override(tariffs) => {
798 let cdr = cdr.map(v221::cdr::WithTariffs::discard_tariffs);
799
800 debug!("Using override tariffs");
801 let tariffs = tariffs
802 .iter()
803 .map(tariff::parse)
804 .collect::<Result<Vec<_>, _>>()?;
805
806 Ok(price_v221_cdr_with_tariffs(
807 cdr_elem, cdr, tariffs, timezone,
808 )?)
809 }
810 }
811}
812
813fn price_v221_cdr_with_tariffs(
820 cdr_elem: &crate::cdr::Versioned<'_>,
821 cdr: Caveat<v221::Cdr, Warning>,
822 tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
823 timezone: Tz,
824) -> Verdict<Report> {
825 debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
826 let (cdr, mut warnings) = cdr.into_parts();
827 let v221::Cdr {
828 start_date_time,
829 end_date_time,
830 charging_periods,
831 totals: cdr_totals,
832 } = cdr;
833
834 let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
839 .into_iter()
840 .enumerate()
841 .map(|(index, tariff)| {
842 let (tariff, warnings) = tariff.into_parts();
843 (
844 TariffReport {
845 origin: TariffOrigin {
846 index,
847 id: tariff.id.to_string(),
848 currency: tariff.currency,
849 },
850 warnings: warnings.into_path_map(),
851 },
852 tariff,
853 )
854 })
855 .unzip();
856
857 debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
858
859 let tariffs_normalized = tariff::normalize_all(&tariffs);
860 let Some((tariff_index, tariff)) =
861 tariff::find_first_active(tariffs_normalized, start_date_time)
862 else {
863 return warnings.bail(Warning::NoValidTariff, cdr_elem.as_element());
864 };
865
866 debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
867 debug!(%timezone, "Found timezone");
868 let periods = charging_periods
870 .into_iter()
871 .map(Period::try_from)
872 .collect::<Result<Vec<_>, _>>()
873 .map_err(|err| warning::ErrorSet::with_warn(Warning::Parse(err), cdr_elem.as_element()))?;
874
875 let periods = normalize_periods(periods, end_date_time, timezone);
876 let price_cdr_report = price_periods(&periods, &tariff)
877 .with_element(cdr_elem.as_element())?
878 .gather_warnings_into(&mut warnings);
879
880 let mut report = generate_report(
881 &cdr_totals,
882 timezone,
883 tariff_reports,
884 price_cdr_report,
885 TariffOrigin {
886 index: tariff_index,
887 id: tariff.id().to_owned(),
888 currency: tariff.currency(),
889 },
890 );
891
892 if let Some(total_cost) = report.total_cost.calculated.as_mut() {
893 if let Some(min_price) = tariff.min_price() {
894 if *total_cost < min_price {
895 *total_cost = min_price;
896 warnings.insert(
897 crate::tariff::Warning::TotalCostClampedToMin.into(),
898 cdr_elem.as_element(),
899 );
900 }
901 }
902
903 if let Some(max_price) = tariff.max_price() {
904 if *total_cost > max_price {
905 *total_cost = max_price;
906 warnings.insert(
907 crate::tariff::Warning::TotalCostClampedToMax.into(),
908 cdr_elem.as_element(),
909 );
910 }
911 }
912 }
913
914 Ok(report.into_caveat(warnings))
915}
916
917pub(crate) fn periods(
919 end_date_time: DateTime<Utc>,
920 timezone: Tz,
921 tariff_elem: &crate::tariff::v221::Tariff<'_>,
922 mut periods: Vec<Period>,
923) -> VerdictDeferred<PeriodsReport> {
924 periods.sort_by_key(|p| p.start_date_time);
927 let tariff = Tariff::from_v221(tariff_elem);
928 let periods = normalize_periods(periods, end_date_time, timezone);
929 price_periods(&periods, &tariff)
930}
931
932fn normalize_periods(
933 periods: Vec<Period>,
934 end_date_time: DateTime<Utc>,
935 local_timezone: Tz,
936) -> Vec<PeriodNormalized> {
937 debug!("Normalizing CDR periods");
938
939 let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
941
942 let end_dates = {
944 let mut end_dates = periods
945 .iter()
946 .skip(1)
947 .map(|p| p.start_date_time)
948 .collect::<Vec<_>>();
949
950 end_dates.push(end_date_time);
952 end_dates
953 };
954
955 let periods = periods
956 .into_iter()
957 .zip(end_dates)
958 .enumerate()
959 .map(|(index, (period, end_date_time))| {
960 trace!(index, "processing\n{period:#?}");
961 let Period {
962 start_date_time,
963 consumed,
964 } = period;
965
966 let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
967 let start_snapshot = prev_end_snapshot;
968 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
969
970 let period = PeriodNormalized {
971 consumed,
972 start_snapshot,
973 end_snapshot,
974 };
975 trace!("Adding new period based on the last added\n{period:#?}");
976 period
977 } else {
978 let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
979 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
980
981 let period = PeriodNormalized {
982 consumed,
983 start_snapshot,
984 end_snapshot,
985 };
986 trace!("Adding new period\n{period:#?}");
987 period
988 };
989
990 previous_end_snapshot.replace(period.end_snapshot.clone());
991 period
992 })
993 .collect::<Vec<_>>();
994
995 periods
996}
997
998fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
1000 debug!(count = periods.len(), "Pricing CDR periods");
1001
1002 if tracing::enabled!(tracing::Level::TRACE) {
1003 trace!("# CDR period list:");
1004 for period in periods {
1005 trace!("{period:#?}");
1006 }
1007 }
1008
1009 let period_totals = period_totals(periods, tariff);
1010 let (billed, warnings) = period_totals.calculate_billed()?.into_parts();
1011 let (billable, periods, totals) = billed;
1012 let total_costs = total_costs(&periods, tariff);
1013 let report = PeriodsReport {
1014 billable,
1015 periods,
1016 totals,
1017 total_costs,
1018 };
1019
1020 Ok(report.into_caveat_deferred(warnings))
1021}
1022
1023pub(crate) struct PeriodsReport {
1025 pub billable: Billable,
1027
1028 pub periods: Vec<PeriodReport>,
1030
1031 pub totals: Totals,
1033
1034 pub total_costs: TotalCosts,
1036}
1037
1038#[derive(Debug)]
1044pub struct PeriodReport {
1045 pub start_date_time: DateTime<Utc>,
1047
1048 pub end_date_time: DateTime<Utc>,
1050
1051 pub dimensions: Dimensions,
1053}
1054
1055impl PeriodReport {
1056 pub fn cost(&self) -> Option<Price> {
1058 [
1059 self.dimensions
1060 .duration_charging
1061 .as_ref()
1062 .and_then(Dimension::cost),
1063 self.dimensions
1064 .duration_idle
1065 .as_ref()
1066 .and_then(Dimension::cost),
1067 self.dimensions.flat.cost(),
1068 self.dimensions.energy.as_ref().and_then(Dimension::cost),
1069 ]
1070 .into_iter()
1071 .fold(None, |accum, next| {
1072 if accum.is_none() && next.is_none() {
1073 None
1074 } else {
1075 Some(
1076 accum
1077 .unwrap_or_default()
1078 .saturating_add(next.unwrap_or_default()),
1079 )
1080 }
1081 })
1082 }
1083}
1084
1085#[derive(Debug)]
1090struct PeriodReportScratch {
1091 start_date_time: DateTime<Utc>,
1092 end_date_time: DateTime<Utc>,
1093 dimensions: Dimensions,
1094 step_size_duration_charging: Option<Component>,
1095 step_size_duration_idle: Option<Component>,
1096 step_size_energy: Option<Component>,
1097}
1098
1099impl From<PeriodReportScratch> for PeriodReport {
1100 fn from(scratch: PeriodReportScratch) -> Self {
1101 Self {
1102 start_date_time: scratch.start_date_time,
1103 end_date_time: scratch.end_date_time,
1104 dimensions: scratch.dimensions,
1105 }
1106 }
1107}
1108
1109#[derive(Debug)]
1111struct PeriodTotals {
1112 periods: Vec<PeriodReportScratch>,
1114
1115 totals: Totals,
1117}
1118
1119#[derive(Debug, Default)]
1121pub(crate) struct Totals {
1122 pub energy: Option<Kwh>,
1124
1125 pub duration_charging: Option<TimeDelta>,
1129
1130 pub duration_idle: Option<TimeDelta>,
1134}
1135
1136impl PeriodTotals {
1137 fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
1139 let mut warnings = warning::SetDeferred::new();
1140 let Self {
1141 mut periods,
1142 totals,
1143 } = self;
1144
1145 let billable =
1146 apply_step_sizes(&mut periods, &totals)?.gather_deferred_warnings_into(&mut warnings);
1147
1148 let periods = periods.into_iter().map(PeriodReport::from).collect();
1149
1150 Ok((billable, periods, totals).into_caveat_deferred(warnings))
1151 }
1152}
1153
1154#[derive(Debug)]
1156pub(crate) struct Billable {
1157 duration_charging: Option<TimeDelta>,
1159
1160 duration_idle: Option<TimeDelta>,
1162
1163 energy: Option<Kwh>,
1165}
1166
1167fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1170 let mut has_flat_fee = false;
1171 let mut totals = Totals::default();
1172
1173 debug!(
1174 tariff_id = tariff.id(),
1175 period_count = periods.len(),
1176 "Accumulating dimension totals for each period"
1177 );
1178
1179 let periods = periods
1180 .iter()
1181 .enumerate()
1182 .map(|(index, period)| {
1183 let mut component_set = tariff.active_components(period);
1184 trace!(
1185 index,
1186 "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1187 );
1188
1189 if component_set.flat.is_some() {
1190 if has_flat_fee {
1191 component_set.flat = None;
1192 } else {
1193 has_flat_fee = true;
1194 }
1195 }
1196
1197 let step_size_duration_charging = if period.consumed.duration_charging.is_some() {
1199 component_set.duration_charging.clone()
1200 } else {
1201 None
1202 };
1203 let step_size_duration_idle = if period.consumed.duration_idle.is_some() {
1204 component_set.duration_idle.clone()
1205 } else {
1206 None
1207 };
1208 let step_size_energy = if period.consumed.energy.is_some() {
1209 component_set.energy.clone()
1210 } else {
1211 None
1212 };
1213
1214 let dimensions = Dimensions::new(component_set, &period.consumed);
1215
1216 trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1217
1218 if let Some(dim) = &dimensions.duration_charging {
1219 let acc = totals.duration_charging.get_or_insert_default();
1220 *acc = acc.saturating_add(dim.volume);
1221 }
1222
1223 if let Some(dim) = &dimensions.energy {
1224 let acc = totals.energy.get_or_insert_default();
1225 *acc = acc.saturating_add(dim.volume);
1226 }
1227
1228 if let Some(dim) = &dimensions.duration_idle {
1229 let acc = totals.duration_idle.get_or_insert_default();
1230 *acc = acc.saturating_add(dim.volume);
1231 }
1232
1233 trace!(period_index = index, ?totals, "Update totals");
1234
1235 PeriodReportScratch {
1236 start_date_time: period.start_snapshot.date_time,
1237 end_date_time: period.end_snapshot.date_time,
1238 dimensions,
1239 step_size_duration_charging,
1240 step_size_duration_idle,
1241 step_size_energy,
1242 }
1243 })
1244 .collect::<Vec<_>>();
1245
1246 PeriodTotals { periods, totals }
1247}
1248
1249#[derive(Debug, Default)]
1251pub(crate) struct TotalCosts {
1252 pub energy: Option<Price>,
1254
1255 pub fixed: Option<Price>,
1257
1258 pub duration_charging: Option<Price>,
1260
1261 pub duration_idle: Option<Price>,
1263}
1264
1265impl TotalCosts {
1266 pub(crate) fn total(&self) -> Option<Price> {
1270 let Self {
1271 energy,
1272 fixed,
1273 duration_charging,
1274 duration_idle,
1275 } = self;
1276 debug!(
1277 energy = %DisplayOption(*energy),
1278 fixed = %DisplayOption(*fixed),
1279 duration_charging = %DisplayOption(*duration_charging),
1280 duration_idle = %DisplayOption(*duration_idle),
1281 "Calculating total costs."
1282 );
1283 [energy, fixed, duration_charging, duration_idle]
1284 .into_iter()
1285 .fold(None, |accum: Option<Price>, next| match (accum, next) {
1286 (None, None) => None,
1287 _ => Some(
1288 accum
1289 .unwrap_or_default()
1290 .saturating_add(next.unwrap_or_default()),
1291 ),
1292 })
1293 }
1294}
1295
1296fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1298 let mut total_costs = TotalCosts::default();
1299
1300 debug!(
1301 tariff_id = tariff.id(),
1302 period_count = periods.len(),
1303 "Accumulating dimension costs for each period"
1304 );
1305 for (index, period) in periods.iter().enumerate() {
1306 let dimensions = &period.dimensions;
1307
1308 trace!(period_index = index, "Processing period");
1309
1310 let energy_cost = dimensions.energy.as_ref().and_then(Dimension::cost);
1311 let fixed_cost = dimensions.flat.cost();
1312 let duration_charging_cost = dimensions
1313 .duration_charging
1314 .as_ref()
1315 .and_then(Dimension::cost);
1316 let duration_idle_cost = dimensions.duration_idle.as_ref().and_then(Dimension::cost);
1317
1318 trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1319 trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1320 trace!(?total_costs.duration_idle, ?duration_idle_cost, "Idle cost");
1321 trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1322
1323 total_costs.energy = match (total_costs.energy, energy_cost) {
1324 (None, None) => None,
1325 (total, period) => Some(
1326 total
1327 .unwrap_or_default()
1328 .saturating_add(period.unwrap_or_default()),
1329 ),
1330 };
1331
1332 total_costs.duration_charging =
1333 match (total_costs.duration_charging, duration_charging_cost) {
1334 (None, None) => None,
1335 (total, period) => Some(
1336 total
1337 .unwrap_or_default()
1338 .saturating_add(period.unwrap_or_default()),
1339 ),
1340 };
1341
1342 total_costs.duration_idle = match (total_costs.duration_idle, duration_idle_cost) {
1343 (None, None) => None,
1344 (total, period) => Some(
1345 total
1346 .unwrap_or_default()
1347 .saturating_add(period.unwrap_or_default()),
1348 ),
1349 };
1350
1351 total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1352 (None, None) => None,
1353 (total, period) => Some(
1354 total
1355 .unwrap_or_default()
1356 .saturating_add(period.unwrap_or_default()),
1357 ),
1358 };
1359
1360 trace!(period_index = index, ?total_costs, "Update totals");
1361 }
1362
1363 total_costs
1364}
1365
1366fn generate_report(
1367 cdr_totals: &v221::cdr::Totals,
1368 timezone: Tz,
1369 tariff_reports: Vec<TariffReport>,
1370 price_periods_report: PeriodsReport,
1371 tariff_used: TariffOrigin,
1372) -> Report {
1373 let PeriodsReport {
1374 billable,
1375 periods,
1376 totals,
1377 total_costs,
1378 } = price_periods_report;
1379 trace!("Update billed totals {billable:#?}");
1380
1381 let total_cost = total_costs.total();
1382
1383 debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1384
1385 let total_time = {
1386 debug!(
1387 period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1388 period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1389 "Calculating `total_time`"
1390 );
1391
1392 periods
1393 .first()
1394 .zip(periods.last())
1395 .map(|(first, last)| {
1396 last.end_date_time
1397 .signed_duration_since(first.start_date_time)
1398 })
1399 .unwrap_or_default()
1400 };
1401 debug!(total_time = %Hms(total_time));
1402
1403 let report = Report {
1404 periods,
1405 tariff_used,
1406 timezone: timezone.to_string(),
1407 billed_idle_time: billable.duration_idle,
1408 billed_energy: billable.energy.round_to_ocpi_scale(),
1409 billed_charging_time: billable.duration_charging,
1410 tariff_reports,
1411 total_charging_time: totals.duration_charging,
1412 total_cost: Total {
1413 cdr: cdr_totals.cost.round_to_ocpi_scale(),
1414 calculated: total_cost.round_to_ocpi_scale(),
1415 },
1416 total_charging_time_cost: Total {
1417 cdr: cdr_totals.duration_charging_cost.round_to_ocpi_scale(),
1418 calculated: total_costs.duration_charging.round_to_ocpi_scale(),
1419 },
1420 total_time: Total {
1421 cdr: cdr_totals.duration_charging,
1422 calculated: total_time,
1423 },
1424 total_idle_cost: Total {
1425 cdr: cdr_totals.duration_idle_cost.round_to_ocpi_scale(),
1426 calculated: total_costs.duration_idle.round_to_ocpi_scale(),
1427 },
1428 total_idle_time: Total {
1429 cdr: cdr_totals.duration_idle,
1430 calculated: totals.duration_idle,
1431 },
1432 total_energy_cost: Total {
1433 cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
1434 calculated: total_costs.energy.round_to_ocpi_scale(),
1435 },
1436 total_energy: Total {
1437 cdr: cdr_totals.energy.round_to_ocpi_scale(),
1438 calculated: totals.energy.round_to_ocpi_scale(),
1439 },
1440 total_fixed_cost: Total {
1441 cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
1442 calculated: total_costs.fixed.round_to_ocpi_scale(),
1443 },
1444 };
1445
1446 trace!("{report:#?}");
1447
1448 report
1449}
1450
1451fn apply_step_sizes(
1454 periods: &mut [PeriodReportScratch],
1455 totals: &Totals,
1456) -> VerdictDeferred<Billable> {
1457 let mut warnings = warning::SetDeferred::new();
1458
1459 let has_idle_step_size = periods.iter().any(|p| p.step_size_duration_idle.is_some());
1460
1461 let duration_charging = if let Some(total) = totals.duration_charging {
1462 let mut result = Some(total);
1463 for period in periods.iter_mut().rev() {
1464 let Some(step) = period
1465 .step_size_duration_charging
1466 .as_ref()
1467 .map(|c| c.step_size)
1468 else {
1469 continue;
1470 };
1471 if has_idle_step_size {
1472 result = Some(total);
1473 } else if let Some(dim) = period.dimensions.duration_charging.as_mut() {
1474 let dt = duration_step_size(total, &mut dim.billed_volume, step)?
1475 .gather_deferred_warnings_into(&mut warnings);
1476 result = Some(dt);
1477 }
1478 break;
1479 }
1480 result
1481 } else {
1482 None
1483 };
1484
1485 let duration_idle = if let Some(total) = totals.duration_idle {
1486 let mut result = Some(total);
1487 for period in periods.iter_mut().rev() {
1488 let Some(step) = period.step_size_duration_idle.as_ref().map(|c| c.step_size) else {
1489 continue;
1490 };
1491 if let Some(dim) = period.dimensions.duration_idle.as_mut() {
1492 let dt = duration_step_size(total, &mut dim.billed_volume, step)?
1493 .gather_deferred_warnings_into(&mut warnings);
1494 result = Some(dt);
1495 }
1496 break;
1497 }
1498 result
1499 } else {
1500 None
1501 };
1502
1503 let energy = if let Some(total) = totals.energy {
1504 let mut result = Some(total);
1505 for period in periods.iter_mut().rev() {
1506 let Some(step) = period.step_size_energy.as_ref().map(|c| c.step_size) else {
1507 continue;
1508 };
1509 if step == 0 {
1510 result = Some(total);
1511 } else {
1512 let step_dec = Decimal::from(step);
1513 if let Some(dim) = period.dimensions.energy.as_mut() {
1514 let Some(watt_hours) = total.watt_hours().checked_div(step_dec) else {
1515 return warnings.bail(duration::Warning::Overflow.into());
1516 };
1517 let total_billed_volume =
1518 Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_dec));
1519 let period_delta_volume = total_billed_volume.saturating_sub(total);
1520 dim.billed_volume = dim.billed_volume.saturating_add(period_delta_volume);
1521 result = Some(total_billed_volume);
1522 }
1523 }
1524 break;
1525 }
1526 result
1527 } else {
1528 None
1529 };
1530
1531 Ok(Billable {
1532 duration_charging,
1533 duration_idle,
1534 energy,
1535 }
1536 .into_caveat_deferred(warnings))
1537}
1538
1539fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1541 Decimal::from(delta.num_milliseconds())
1542 .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1543 .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1544}
1545
1546fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1548 let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1549 let Ok(millis) = i64::try_from(millis) else {
1550 return Err(warning::ErrorSetDeferred::with_warn(
1551 duration::Warning::Overflow.into(),
1552 ));
1553 };
1554 let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1555 return Err(warning::ErrorSetDeferred::with_warn(
1556 duration::Warning::Overflow.into(),
1557 ));
1558 };
1559 Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1560}
1561
1562fn duration_step_size(
1564 total_volume: TimeDelta,
1565 period_billed_volume: &mut TimeDelta,
1566 step_size: u64,
1567) -> VerdictDeferred<TimeDelta> {
1568 if step_size == 0 {
1569 return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1570 }
1571
1572 let total_seconds = delta_as_seconds_dec(total_volume);
1573 let step_size = Decimal::from(step_size);
1574
1575 let Some(x) = total_seconds.checked_div(step_size) else {
1576 return Err(warning::ErrorSetDeferred::with_warn(
1577 duration::Warning::Overflow.into(),
1578 ));
1579 };
1580 let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1581
1582 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1583 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1584
1585 Ok(total_billed_volume)
1586}
1587
1588fn parse_cdr<'buf>(cdr: &crate::cdr::Versioned<'buf>) -> Verdict<v221::cdr::WithTariffs<'buf>> {
1589 match cdr.version() {
1590 Version::V211 => {
1591 let cdr = v211::cdr::WithTariffs::from_json(cdr.as_element())?;
1592 Ok(cdr.map(v221::cdr::WithTariffs::from))
1593 }
1594 Version::V221 => v221::cdr::WithTariffs::from_json(cdr.as_element()),
1595 }
1596}