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_warning_path_map;
20
21mod tariff;
22mod v211;
23mod v221;
24
25use std::{borrow::Cow, collections::BTreeMap, fmt, ops::Range};
26
27use chrono::{DateTime, Datelike as _, TimeDelta, Utc};
28use chrono_tz::Tz;
29use rust_decimal::Decimal;
30use tracing::{debug, error, instrument, trace};
31
32use crate::{
33 country, currency, datetime,
34 duration::{self, AsHms as _, Hms},
35 enumeration, from_warning_all,
36 json::{self, FromJson as _},
37 money::{self, VatOrigin},
38 number::{self, RoundDecimal as _},
39 string,
40 warning::{
41 self, GatherDeferredWarnings as _, GatherWarnings as _, IntoCaveat as _,
42 IntoCaveatDeferred as _, VerdictExt as _, WithElement as _,
43 },
44 Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, ParseError, Price, SaturatingAdd as _,
45 SaturatingSub as _, Version, Versioned as _,
46};
47
48use tariff::Tariff;
49
50pub type Verdict<T> = crate::Verdict<T, Warning>;
51type VerdictDeferred<T> = warning::VerdictDeferred<T, Warning>;
52
53#[derive(Debug)]
58struct PeriodNormalized {
59 consumed: Consumed,
61
62 start_snapshot: TotalsSnapshot,
64
65 end_snapshot: TotalsSnapshot,
67}
68
69#[derive(Clone)]
71#[cfg_attr(test, derive(Default))]
72pub(crate) struct Consumed {
73 pub current_max: Option<Ampere>,
75
76 pub current_min: Option<Ampere>,
78
79 pub duration_charging: Option<TimeDelta>,
81
82 pub duration_idle: Option<TimeDelta>,
84
85 pub energy: Option<Kwh>,
87
88 pub power_max: Option<Kw>,
90
91 pub power_min: Option<Kw>,
93}
94
95impl fmt::Debug for Consumed {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.debug_struct("Consumed")
98 .field("current_max", &self.current_max)
99 .field("current_min", &self.current_min)
100 .field(
101 "duration_charging",
102 &self.duration_charging.map(|dt| dt.as_hms()),
103 )
104 .field("duration_idle", &self.duration_idle.map(|dt| dt.as_hms()))
105 .field("energy", &self.energy)
106 .field("power_max", &self.power_max)
107 .field("power_min", &self.power_min)
108 .finish()
109 }
110}
111
112#[derive(Clone)]
114struct TotalsSnapshot {
115 date_time: DateTime<Utc>,
117
118 energy: Kwh,
120
121 local_timezone: Tz,
123
124 duration_charging: TimeDelta,
126
127 duration_total: TimeDelta,
129}
130
131impl fmt::Debug for TotalsSnapshot {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.debug_struct("TotalsSnapshot")
134 .field("date_time", &self.date_time)
135 .field("energy", &self.energy)
136 .field("local_timezone", &self.local_timezone)
137 .field("duration_charging", &self.duration_charging.as_hms())
138 .field("duration_total", &self.duration_total.as_hms())
139 .finish()
140 }
141}
142
143impl TotalsSnapshot {
144 fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
146 Self {
147 date_time,
148 energy: Kwh::zero(),
149 local_timezone,
150 duration_charging: TimeDelta::zero(),
151 duration_total: TimeDelta::zero(),
152 }
153 }
154
155 fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
157 let duration = date_time.signed_duration_since(self.date_time);
158
159 let mut next = Self {
160 date_time,
161 energy: self.energy,
162 local_timezone: self.local_timezone,
163 duration_charging: self.duration_charging,
164 duration_total: self.duration_total.saturating_add(duration),
165 };
166
167 if let Some(duration) = consumed.duration_charging {
168 next.duration_charging = next.duration_charging.saturating_add(duration);
169 }
170
171 if let Some(energy) = consumed.energy {
172 next.energy = next.energy.saturating_add(energy);
173 }
174 next
175 }
176
177 fn local_time(&self) -> chrono::NaiveTime {
179 self.date_time.with_timezone(&self.local_timezone).time()
180 }
181
182 fn local_date(&self) -> chrono::NaiveDate {
184 self.date_time
185 .with_timezone(&self.local_timezone)
186 .date_naive()
187 }
188
189 fn local_weekday(&self) -> chrono::Weekday {
191 self.date_time.with_timezone(&self.local_timezone).weekday()
192 }
193}
194
195pub struct Report {
198 pub periods: Vec<PeriodReport>,
200
201 pub tariff_used: TariffOrigin,
203
204 pub tariff_reports: Vec<TariffReport>,
208
209 pub timezone: String,
211
212 pub billed_charging_time: Option<TimeDelta>,
215
216 pub billed_energy: Option<Kwh>,
218
219 pub billed_idle_time: Option<TimeDelta>,
221
222 pub total_charging_time: Option<TimeDelta>,
228
229 pub total_energy: Total<Kwh, Option<Kwh>>,
231
232 pub total_idle_time: Total<Option<TimeDelta>>,
239
240 pub total_time: Total<TimeDelta>,
242
243 pub total_cost: Total<Price, Option<Price>>,
246
247 pub total_energy_cost: Total<Option<Price>>,
249
250 pub total_fixed_cost: Total<Option<Price>>,
253
254 pub total_idle_cost: Total<Option<Price>>,
261
262 pub total_reservation_cost: Total<Option<Price>>,
264
265 pub total_charging_time_cost: Total<Option<Price>>,
270}
271
272impl fmt::Debug for Report {
273 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 f.debug_struct("Report")
275 .field("periods", &self.periods)
276 .field("tariff_used", &self.tariff_used)
277 .field("tariff_reports", &self.tariff_reports)
278 .field("timezone", &self.timezone)
279 .field(
280 "billed_charging_time",
281 &self.billed_charging_time.map(|dt| dt.as_hms()),
282 )
283 .field("billed_energy", &self.billed_energy)
284 .field(
285 "billed_idle_time",
286 &self.billed_idle_time.map(|dt| dt.as_hms()),
287 )
288 .field(
289 "total_charging_time",
290 &self.total_charging_time.map(|dt| dt.as_hms()),
291 )
292 .field("total_energy", &self.total_energy)
293 .field("total_idle_time", &self.total_idle_time)
294 .field("total_time", &self.total_time)
295 .field("total_cost", &self.total_cost)
296 .field("total_energy_cost", &self.total_energy_cost)
297 .field("total_fixed_cost", &self.total_fixed_cost)
298 .field("total_idle_cost", &self.total_idle_cost)
299 .field("total_reservation_cost", &self.total_reservation_cost)
300 .field("total_charging_time_cost", &self.total_charging_time_cost)
301 .finish()
302 }
303}
304
305#[derive(Debug)]
307pub enum Warning {
308 Country(country::Warning),
309 Currency(currency::Warning),
310 DateTime(datetime::Warning),
311 Decode(json::decode::Warning),
312 Duration(duration::Warning),
313 Enum(enumeration::Warning),
314
315 CountryShouldBeAlpha2,
319
320 DimensionShouldHaveVolume {
322 dimension_name: &'static str,
323 },
324
325 FieldInvalidType {
327 expected_type: json::ValueKind,
329 },
330
331 FieldInvalidValue {
333 value: String,
335
336 message: Cow<'static, str>,
338 },
339
340 FieldRequired {
342 field_name: Cow<'static, str>,
343 },
344
345 InternalError,
349
350 Money(money::Warning),
351
352 NoPeriods,
354
355 NoValidTariff,
365
366 Number(number::Warning),
367
368 Parse(ParseError),
370
371 PeriodsOutsideStartEndDateTime {
374 cdr_range: Range<DateTime<Utc>>,
375 period_range: PeriodRange,
376 },
377
378 String(string::Warning),
379
380 Tariff(crate::tariff::Warning),
383}
384
385impl Warning {
386 fn field_invalid_value(
388 value: impl Into<String>,
389 message: impl Into<Cow<'static, str>>,
390 ) -> Self {
391 Warning::FieldInvalidValue {
392 value: value.into(),
393 message: message.into(),
394 }
395 }
396}
397
398impl fmt::Display for Warning {
399 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400 match self {
401 Self::Country(warning) => write!(f, "{warning}"),
402 Self::CountryShouldBeAlpha2 => {
403 f.write_str("The `$.country` field should be an alpha-2 country code.")
404 }
405 Self::Currency(warning) => write!(f, "{warning}"),
406 Self::DateTime(warning) => write!(f, "{warning}"),
407 Self::Decode(warning) => write!(f, "{warning}"),
408 Self::DimensionShouldHaveVolume { dimension_name } => {
409 write!(f, "Dimension `{dimension_name}` should have volume")
410 }
411 Self::Duration(warning) => write!(f, "{warning}"),
412 Self::Enum(warning) => write!(f, "{warning}"),
413 Self::FieldInvalidType { expected_type } => {
414 write!(f, "Field has invalid type. Expected type `{expected_type}`")
415 }
416 Self::FieldInvalidValue { value, message } => {
417 write!(f, "Field has invalid value `{value}`: {message}")
418 }
419 Self::FieldRequired { field_name } => {
420 write!(f, "Field is required: `{field_name}`")
421 }
422 Self::InternalError => f.write_str("Internal error"),
423 Self::Money(warning) => write!(f, "{warning}"),
424 Self::NoPeriods => f.write_str("The CDR has no charging periods"),
425 Self::NoValidTariff => {
426 f.write_str("No valid tariff has been found in the list of provided tariffs")
427 }
428 Self::Number(warning) => write!(f, "{warning}"),
429 Self::Parse(err) => {
430 write!(f, "{err}")
431 }
432 Self::PeriodsOutsideStartEndDateTime {
433 cdr_range: Range { start, end },
434 period_range,
435 } => {
436 write!(
437 f,
438 "The CDR's charging period time range is not contained within the `start_date_time` \
439 and `end_date_time`; cdr: [start: {start}, end: {end}], period: {period_range}",
440 )
441 }
442 Self::String(warning) => write!(f, "{warning}"),
443 Self::Tariff(warnings) => {
444 write!(f, "Tariff warnings: {warnings:?}")
445 }
446 }
447 }
448}
449
450impl crate::Warning for Warning {
451 fn id(&self) -> warning::Id {
452 match self {
453 Self::Country(warning) => warning.id(),
454 Self::CountryShouldBeAlpha2 => warning::Id::from_static("country_should_be_alpha_2"),
455 Self::Currency(warning) => warning.id(),
456 Self::DateTime(warning) => warning.id(),
457 Self::Decode(warning) => warning.id(),
458 Self::DimensionShouldHaveVolume { dimension_name } => {
459 warning::Id::from_string(format!("dimension_should_have_volume({dimension_name})"))
460 }
461 Self::Duration(warning) => warning.id(),
462 Self::Enum(warning) => warning.id(),
463 Self::FieldInvalidType { expected_type } => {
464 warning::Id::from_string(format!("field_invalid_type({expected_type})"))
465 }
466 Self::FieldInvalidValue { value, .. } => {
467 warning::Id::from_string(format!("field_invalid_value({value})"))
468 }
469 Self::FieldRequired { field_name } => {
470 warning::Id::from_string(format!("field_required({field_name})"))
471 }
472 Self::InternalError => warning::Id::from_static("internal_error"),
473 Self::Money(warning) => warning.id(),
474 Self::NoPeriods => warning::Id::from_static("no_periods"),
475 Self::NoValidTariff => warning::Id::from_static("no_valid_tariff"),
476 Self::Number(warning) => warning.id(),
477 Self::Parse(ParseError { object: _, kind }) => kind.id(),
478 Self::PeriodsOutsideStartEndDateTime { .. } => {
479 warning::Id::from_static("periods_outside_start_end_date_time")
480 }
481 Self::String(warning) => warning.id(),
482 Self::Tariff(warning) => warning.id(),
483 }
484 }
485}
486
487from_warning_all!(
488 country::Warning => Warning::Country,
489 currency::Warning => Warning::Currency,
490 datetime::Warning => Warning::DateTime,
491 duration::Warning => Warning::Duration,
492 enumeration::Warning => Warning::Enum,
493 json::decode::Warning => Warning::Decode,
494 money::Warning => Warning::Money,
495 number::Warning => Warning::Number,
496 string::Warning => Warning::String,
497 crate::tariff::Warning => Warning::Tariff
498);
499
500#[derive(Debug)]
502pub struct TariffReport {
503 pub origin: TariffOrigin,
505
506 pub warnings: BTreeMap<warning::Path, Vec<crate::tariff::Warning>>,
510}
511
512#[derive(Clone, Debug)]
514pub struct TariffOrigin {
515 pub index: usize,
517
518 pub id: String,
520
521 pub currency: currency::Code,
523}
524
525#[derive(Debug)]
527pub(crate) struct Period {
528 pub start_date_time: DateTime<Utc>,
530
531 pub consumed: Consumed,
533}
534
535#[derive(Debug)]
537pub struct Dimensions {
538 pub energy: Dimension<Kwh>,
540
541 pub flat: Dimension<()>,
543
544 pub duration_charging: Dimension<TimeDelta>,
546
547 pub duration_idle: Dimension<TimeDelta>,
549}
550
551impl Dimensions {
552 fn new(components: ComponentSet, consumed: &Consumed) -> Self {
554 let ComponentSet {
555 energy: energy_price,
556 flat: flat_price,
557 duration_charging: duration_charging_price,
558 duration_idle: duration_idle_price,
559 } = components;
560
561 let Consumed {
562 duration_charging,
563 duration_idle,
564 energy,
565 current_max: _,
566 current_min: _,
567 power_max: _,
568 power_min: _,
569 } = consumed;
570
571 Self {
572 energy: Dimension {
573 price: energy_price,
574 volume: *energy,
575 billed_volume: *energy,
576 },
577 flat: Dimension {
578 price: flat_price,
579 volume: Some(()),
580 billed_volume: Some(()),
581 },
582 duration_charging: Dimension {
583 price: duration_charging_price,
584 volume: *duration_charging,
585 billed_volume: *duration_charging,
586 },
587 duration_idle: Dimension {
588 price: duration_idle_price,
589 volume: *duration_idle,
590 billed_volume: *duration_idle,
591 },
592 }
593 }
594}
595
596#[derive(Debug)]
597pub struct Dimension<V> {
599 pub price: Option<Component>,
603
604 pub volume: Option<V>,
608
609 pub billed_volume: Option<V>,
617}
618
619impl<V: Cost> Dimension<V> {
620 pub fn cost(&self) -> Option<Price> {
622 let (Some(volume), Some(price_component)) = (&self.billed_volume, &self.price) else {
623 return None;
624 };
625
626 let excl_vat = volume.cost(price_component.price);
627
628 let incl_vat = match price_component.vat {
629 VatOrigin::Provided(vat) => Some(excl_vat.apply_vat(vat)),
630 VatOrigin::NotProvided => Some(excl_vat),
631 VatOrigin::Unknown => None,
632 };
633
634 Some(Price { excl_vat, incl_vat })
635 }
636}
637
638#[derive(Debug)]
643pub struct ComponentSet {
644 pub energy: Option<Component>,
646
647 pub flat: Option<Component>,
649
650 pub duration_charging: Option<Component>,
652
653 pub duration_idle: Option<Component>,
655}
656
657impl ComponentSet {
658 fn has_all_components(&self) -> bool {
660 let Self {
661 energy,
662 flat,
663 duration_charging,
664 duration_idle,
665 } = self;
666
667 flat.is_some() && energy.is_some() && duration_idle.is_some() && duration_charging.is_some()
668 }
669}
670
671#[derive(Clone, Debug)]
676pub struct Component {
677 price: Money,
679
680 vat: VatOrigin,
683
684 step_size: u64,
692}
693
694impl Component {
695 fn new(component: &crate::tariff::v221::PriceComponent) -> Self {
697 let crate::tariff::v221::PriceComponent {
698 price,
699 vat,
700 step_size,
701 dimension_type: _,
702 } = component;
703
704 Self {
705 price: *price,
706 vat: *vat,
707 step_size: *step_size,
708 }
709 }
710
711 pub fn price(&self) -> Money {
713 self.price
714 }
715
716 fn into_period(self, period_index: usize) -> PeriodComponent {
718 PeriodComponent {
719 period_index,
720 component: self,
721 }
722 }
723}
724
725#[derive(Debug)]
727struct PeriodComponent {
728 period_index: usize,
730
731 component: Component,
733}
734#[derive(Debug)]
748pub struct Total<TCdr, TCalc = TCdr> {
749 pub cdr: TCdr,
751
752 pub calculated: TCalc,
754}
755
756#[derive(Debug)]
758pub enum PeriodRange {
759 Many(Range<DateTime<Utc>>),
762
763 Single(DateTime<Utc>),
765}
766
767impl fmt::Display for PeriodRange {
768 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
769 match self {
770 PeriodRange::Many(Range { start, end }) => write!(f, "[start: {start}, end: {end}]"),
771 PeriodRange::Single(date_time) => write!(f, "{date_time}"),
772 }
773 }
774}
775
776#[derive(Debug)]
780pub enum TariffSource<'buf> {
781 UseCdr,
783
784 Override(Vec<crate::tariff::Versioned<'buf>>),
786}
787
788impl<'buf> TariffSource<'buf> {
789 pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
791 Self::Override(vec![tariff])
792 }
793}
794
795#[instrument(skip_all)]
799pub(super) fn cdr(
800 cdr_elem: &crate::cdr::Versioned<'_>,
801 tariff_source: TariffSource<'_>,
802 timezone: Tz,
803) -> Verdict<Report> {
804 let source_version = cdr_elem.version();
805 let cdr = parse_cdr(cdr_elem)?;
806
807 match tariff_source {
808 TariffSource::UseCdr => {
809 let (v221::cdr::WithTariffs { cdr, tariffs }, warnings) = cdr.into_parts();
810 debug!("Using tariffs from CDR");
811 let tariffs = tariffs
812 .iter()
813 .map(|elem| {
814 match source_version {
816 Version::V221 => crate::tariff::v221::Tariff::from_json(elem),
817 Version::V211 => {
818 let tariff = crate::tariff::v211::Tariff::from_json(elem);
819 tariff.map_caveat(crate::tariff::v221::Tariff::from)
822 }
823 }
824 })
825 .collect::<Result<Vec<_>, _>>()?;
826
827 let cdr = cdr.into_caveat(warnings);
828
829 Ok(price_v221_cdr_with_tariffs(
830 cdr_elem, cdr, tariffs, timezone,
831 )?)
832 }
833 TariffSource::Override(tariffs) => {
834 let cdr = cdr.map(v221::cdr::WithTariffs::discard_tariffs);
835
836 debug!("Using override tariffs");
837 let tariffs = tariffs
838 .iter()
839 .map(tariff::parse)
840 .collect::<Result<Vec<_>, _>>()?;
841
842 Ok(price_v221_cdr_with_tariffs(
843 cdr_elem, cdr, tariffs, timezone,
844 )?)
845 }
846 }
847}
848
849fn price_v221_cdr_with_tariffs(
856 cdr_elem: &crate::cdr::Versioned<'_>,
857 cdr: Caveat<v221::Cdr, Warning>,
858 tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
859 timezone: Tz,
860) -> Verdict<Report> {
861 debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
862 let (cdr, mut warnings) = cdr.into_parts();
863 let v221::Cdr {
864 start_date_time,
865 end_date_time,
866 charging_periods,
867 totals: cdr_totals,
868 } = cdr;
869
870 let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
875 .into_iter()
876 .enumerate()
877 .map(|(index, tariff)| {
878 let (tariff, warnings) = tariff.into_parts();
879 (
880 TariffReport {
881 origin: TariffOrigin {
882 index,
883 id: tariff.id.to_string(),
884 currency: tariff.currency,
885 },
886 warnings: warnings.into_path_map(),
887 },
888 tariff,
889 )
890 })
891 .unzip();
892
893 debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
894
895 let tariffs_normalized = tariff::normalize_all(&tariffs);
896 let Some((tariff_index, tariff)) =
897 tariff::find_first_active(tariffs_normalized, start_date_time)
898 else {
899 return warnings.bail(Warning::NoValidTariff, cdr_elem.as_element());
900 };
901
902 debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
903 debug!(%timezone, "Found timezone");
904 let periods = charging_periods
906 .into_iter()
907 .map(Period::try_from)
908 .collect::<Result<Vec<_>, _>>()
909 .map_err(|err| warning::ErrorSet::with_warn(Warning::Parse(err), cdr_elem.as_element()))?;
910
911 let periods = normalize_periods(periods, end_date_time, timezone);
912 let price_cdr_report = price_periods(&periods, &tariff)
913 .with_element(cdr_elem.as_element())?
914 .gather_warnings_into(&mut warnings);
915
916 let report = generate_report(
917 &cdr_totals,
918 timezone,
919 tariff_reports,
920 price_cdr_report,
921 TariffOrigin {
922 index: tariff_index,
923 id: tariff.id().to_owned(),
924 currency: tariff.currency(),
925 },
926 );
927
928 Ok(report.into_caveat(warnings))
929}
930
931pub(crate) fn periods(
933 end_date_time: DateTime<Utc>,
934 timezone: Tz,
935 tariff_elem: &crate::tariff::v221::Tariff<'_>,
936 mut periods: Vec<Period>,
937) -> VerdictDeferred<PeriodsReport> {
938 periods.sort_by_key(|p| p.start_date_time);
941 let tariff = Tariff::from_v221(tariff_elem);
942 let periods = normalize_periods(periods, end_date_time, timezone);
943 price_periods(&periods, &tariff)
944}
945
946fn normalize_periods(
947 periods: Vec<Period>,
948 end_date_time: DateTime<Utc>,
949 local_timezone: Tz,
950) -> Vec<PeriodNormalized> {
951 debug!("Normalizing CDR periods");
952
953 let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
955
956 let end_dates = {
958 let mut end_dates = periods
959 .iter()
960 .skip(1)
961 .map(|p| p.start_date_time)
962 .collect::<Vec<_>>();
963
964 end_dates.push(end_date_time);
966 end_dates
967 };
968
969 let periods = periods
970 .into_iter()
971 .zip(end_dates)
972 .enumerate()
973 .map(|(index, (period, end_date_time))| {
974 trace!(index, "processing\n{period:#?}");
975 let Period {
976 start_date_time,
977 consumed,
978 } = period;
979
980 let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
981 let start_snapshot = prev_end_snapshot;
982 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
983
984 let period = PeriodNormalized {
985 consumed,
986 start_snapshot,
987 end_snapshot,
988 };
989 trace!("Adding new period based on the last added\n{period:#?}");
990 period
991 } else {
992 let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
993 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
994
995 let period = PeriodNormalized {
996 consumed,
997 start_snapshot,
998 end_snapshot,
999 };
1000 trace!("Adding new period\n{period:#?}");
1001 period
1002 };
1003
1004 previous_end_snapshot.replace(period.end_snapshot.clone());
1005 period
1006 })
1007 .collect::<Vec<_>>();
1008
1009 periods
1010}
1011
1012fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
1014 debug!(count = periods.len(), "Pricing CDR periods");
1015
1016 if tracing::enabled!(tracing::Level::TRACE) {
1017 trace!("# CDR period list:");
1018 for period in periods {
1019 trace!("{period:#?}");
1020 }
1021 }
1022
1023 let period_totals = period_totals(periods, tariff);
1024 let (billed, warnings) = period_totals.calculate_billed()?.into_parts();
1025 let (billable, periods, totals) = billed;
1026 let total_costs = total_costs(&periods, tariff);
1027 let report = PeriodsReport {
1028 billable,
1029 periods,
1030 totals,
1031 total_costs,
1032 };
1033
1034 Ok(report.into_caveat_deferred(warnings))
1035}
1036
1037pub(crate) struct PeriodsReport {
1039 pub billable: Billable,
1041
1042 pub periods: Vec<PeriodReport>,
1044
1045 pub totals: Totals,
1047
1048 pub total_costs: TotalCosts,
1050}
1051
1052#[derive(Debug)]
1058pub struct PeriodReport {
1059 pub start_date_time: DateTime<Utc>,
1061
1062 pub end_date_time: DateTime<Utc>,
1064
1065 pub dimensions: Dimensions,
1067}
1068
1069impl PeriodReport {
1070 fn new(period: &PeriodNormalized, dimensions: Dimensions) -> Self {
1072 Self {
1073 start_date_time: period.start_snapshot.date_time,
1074 end_date_time: period.end_snapshot.date_time,
1075 dimensions,
1076 }
1077 }
1078
1079 pub fn cost(&self) -> Option<Price> {
1081 [
1082 self.dimensions.duration_charging.cost(),
1083 self.dimensions.duration_idle.cost(),
1084 self.dimensions.flat.cost(),
1085 self.dimensions.energy.cost(),
1086 ]
1087 .into_iter()
1088 .fold(None, |accum, next| {
1089 if accum.is_none() && next.is_none() {
1090 None
1091 } else {
1092 Some(
1093 accum
1094 .unwrap_or_default()
1095 .saturating_add(next.unwrap_or_default()),
1096 )
1097 }
1098 })
1099 }
1100}
1101
1102#[derive(Debug)]
1104struct PeriodTotals {
1105 periods: Vec<PeriodReport>,
1107
1108 step_size: StepSize,
1110
1111 totals: Totals,
1113}
1114
1115#[derive(Debug, Default)]
1117pub(crate) struct Totals {
1118 pub energy: Option<Kwh>,
1120
1121 pub duration_charging: Option<TimeDelta>,
1125
1126 pub duration_idle: Option<TimeDelta>,
1130}
1131
1132impl PeriodTotals {
1133 fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
1137 let mut warnings = warning::SetDeferred::new();
1138 let Self {
1139 mut periods,
1140 step_size,
1141 totals,
1142 } = self;
1143 let duration_charging = totals
1144 .duration_charging
1145 .map(|dt| step_size.apply_time(&mut periods, dt))
1146 .transpose()?
1147 .gather_deferred_warnings_into(&mut warnings);
1148 let energy = totals
1149 .energy
1150 .map(|kwh| step_size.apply_energy(&mut periods, kwh))
1151 .transpose()?
1152 .gather_deferred_warnings_into(&mut warnings);
1153 let duration_idle = totals
1154 .duration_idle
1155 .map(|dt| step_size.apply_idle_time(&mut periods, dt))
1156 .transpose()?
1157 .gather_deferred_warnings_into(&mut warnings);
1158 let billed = Billable {
1159 duration_charging,
1160 duration_idle,
1161 energy,
1162 };
1163 Ok((billed, periods, totals).into_caveat_deferred(warnings))
1164 }
1165}
1166
1167#[derive(Debug)]
1169pub(crate) struct Billable {
1170 duration_charging: Option<TimeDelta>,
1172
1173 duration_idle: Option<TimeDelta>,
1175
1176 energy: Option<Kwh>,
1178}
1179
1180fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1183 let mut has_flat_fee = false;
1184 let mut step_size = StepSize::default();
1185 let mut totals = Totals::default();
1186
1187 debug!(
1188 tariff_id = tariff.id(),
1189 period_count = periods.len(),
1190 "Accumulating dimension totals for each period"
1191 );
1192
1193 let periods = periods
1194 .iter()
1195 .enumerate()
1196 .map(|(index, period)| {
1197 let mut component_set = tariff.active_components(period);
1198 trace!(
1199 index,
1200 "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1201 );
1202
1203 if component_set.flat.is_some() {
1204 if has_flat_fee {
1205 component_set.flat = None;
1206 } else {
1207 has_flat_fee = true;
1208 }
1209 }
1210
1211 step_size.update(index, period, &component_set);
1212
1213 trace!(period_index = index, "Step size updated\n{step_size:#?}");
1214
1215 let dimensions = Dimensions::new(component_set, &period.consumed);
1216
1217 trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1218
1219 if let Some(dt) = dimensions.duration_charging.volume {
1220 let acc = totals.duration_charging.get_or_insert_default();
1221 *acc = acc.saturating_add(dt);
1222 }
1223
1224 if let Some(kwh) = dimensions.energy.volume {
1225 let acc = totals.energy.get_or_insert_default();
1226 *acc = acc.saturating_add(kwh);
1227 }
1228
1229 if let Some(dt) = dimensions.duration_idle.volume {
1230 let acc = totals.duration_idle.get_or_insert_default();
1231 *acc = acc.saturating_add(dt);
1232 }
1233
1234 trace!(period_index = index, ?totals, "Update totals");
1235
1236 PeriodReport::new(period, dimensions)
1237 })
1238 .collect::<Vec<_>>();
1239
1240 PeriodTotals {
1241 periods,
1242 step_size,
1243 totals,
1244 }
1245}
1246
1247#[derive(Debug, Default)]
1249pub(crate) struct TotalCosts {
1250 pub energy: Option<Price>,
1252
1253 pub fixed: Option<Price>,
1255
1256 pub duration_charging: Option<Price>,
1258
1259 pub duration_idle: Option<Price>,
1261}
1262
1263impl TotalCosts {
1264 pub(crate) fn total(&self) -> Option<Price> {
1268 let Self {
1269 energy,
1270 fixed,
1271 duration_charging,
1272 duration_idle,
1273 } = self;
1274 debug!(
1275 energy = %DisplayOption(*energy),
1276 fixed = %DisplayOption(*fixed),
1277 duration_charging = %DisplayOption(*duration_charging),
1278 duration_idle = %DisplayOption(*duration_idle),
1279 "Calculating total costs."
1280 );
1281 [energy, fixed, duration_charging, duration_idle]
1282 .into_iter()
1283 .fold(None, |accum: Option<Price>, next| match (accum, next) {
1284 (None, None) => None,
1285 _ => Some(
1286 accum
1287 .unwrap_or_default()
1288 .saturating_add(next.unwrap_or_default()),
1289 ),
1290 })
1291 }
1292}
1293
1294fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1296 let mut total_costs = TotalCosts::default();
1297
1298 debug!(
1299 tariff_id = tariff.id(),
1300 period_count = periods.len(),
1301 "Accumulating dimension costs for each period"
1302 );
1303 for (index, period) in periods.iter().enumerate() {
1304 let dimensions = &period.dimensions;
1305
1306 trace!(period_index = index, "Processing period");
1307
1308 let energy_cost = dimensions.energy.cost();
1309 let fixed_cost = dimensions.flat.cost();
1310 let duration_charging_cost = dimensions.duration_charging.cost();
1311 let duration_idle_cost = dimensions.duration_idle.cost();
1312
1313 trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1314 trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1315 trace!(?total_costs.duration_idle, ?duration_idle_cost, "Idle cost");
1316 trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1317
1318 total_costs.energy = match (total_costs.energy, energy_cost) {
1319 (None, None) => None,
1320 (total, period) => Some(
1321 total
1322 .unwrap_or_default()
1323 .saturating_add(period.unwrap_or_default()),
1324 ),
1325 };
1326
1327 total_costs.duration_charging =
1328 match (total_costs.duration_charging, duration_charging_cost) {
1329 (None, None) => None,
1330 (total, period) => Some(
1331 total
1332 .unwrap_or_default()
1333 .saturating_add(period.unwrap_or_default()),
1334 ),
1335 };
1336
1337 total_costs.duration_idle = match (total_costs.duration_idle, duration_idle_cost) {
1338 (None, None) => None,
1339 (total, period) => Some(
1340 total
1341 .unwrap_or_default()
1342 .saturating_add(period.unwrap_or_default()),
1343 ),
1344 };
1345
1346 total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1347 (None, None) => None,
1348 (total, period) => Some(
1349 total
1350 .unwrap_or_default()
1351 .saturating_add(period.unwrap_or_default()),
1352 ),
1353 };
1354
1355 trace!(period_index = index, ?total_costs, "Update totals");
1356 }
1357
1358 total_costs
1359}
1360
1361fn generate_report(
1362 cdr_totals: &v221::cdr::Totals,
1363 timezone: Tz,
1364 tariff_reports: Vec<TariffReport>,
1365 price_periods_report: PeriodsReport,
1366 tariff_used: TariffOrigin,
1367) -> Report {
1368 let PeriodsReport {
1369 billable,
1370 periods,
1371 totals,
1372 total_costs,
1373 } = price_periods_report;
1374 trace!("Update billed totals {billable:#?}");
1375
1376 let total_cost = total_costs.total();
1377
1378 debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1379
1380 let total_time = {
1381 debug!(
1382 period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1383 period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1384 "Calculating `total_time`"
1385 );
1386
1387 periods
1388 .first()
1389 .zip(periods.last())
1390 .map(|(first, last)| {
1391 last.end_date_time
1392 .signed_duration_since(first.start_date_time)
1393 })
1394 .unwrap_or_default()
1395 };
1396 debug!(total_time = %Hms(total_time));
1397
1398 let report = Report {
1399 periods,
1400 tariff_used,
1401 timezone: timezone.to_string(),
1402 billed_idle_time: billable.duration_idle,
1403 billed_energy: billable.energy.round_to_ocpi_scale(),
1404 billed_charging_time: billable.duration_charging,
1405 tariff_reports,
1406 total_charging_time: totals.duration_charging,
1407 total_cost: Total {
1408 cdr: cdr_totals.cost.round_to_ocpi_scale(),
1409 calculated: total_cost.round_to_ocpi_scale(),
1410 },
1411 total_charging_time_cost: Total {
1412 cdr: cdr_totals.duration_charging_cost.round_to_ocpi_scale(),
1413 calculated: total_costs.duration_charging.round_to_ocpi_scale(),
1414 },
1415 total_time: Total {
1416 cdr: cdr_totals.duration_charging,
1417 calculated: total_time,
1418 },
1419 total_idle_cost: Total {
1420 cdr: cdr_totals.duration_idle_cost.round_to_ocpi_scale(),
1421 calculated: total_costs.duration_idle.round_to_ocpi_scale(),
1422 },
1423 total_idle_time: Total {
1424 cdr: cdr_totals.duration_idle,
1425 calculated: totals.duration_idle,
1426 },
1427 total_energy_cost: Total {
1428 cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
1429 calculated: total_costs.energy.round_to_ocpi_scale(),
1430 },
1431 total_energy: Total {
1432 cdr: cdr_totals.energy.round_to_ocpi_scale(),
1433 calculated: totals.energy.round_to_ocpi_scale(),
1434 },
1435 total_fixed_cost: Total {
1436 cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
1437 calculated: total_costs.fixed.round_to_ocpi_scale(),
1438 },
1439 total_reservation_cost: Total {
1440 cdr: cdr_totals.reservation_cost.round_to_ocpi_scale(),
1441 calculated: None,
1442 },
1443 };
1444
1445 trace!("{report:#?}");
1446
1447 report
1448}
1449
1450#[derive(Debug, Default)]
1454struct StepSize {
1455 duration_charging: Option<PeriodComponent>,
1456 duration_idle: Option<PeriodComponent>,
1457 energy: Option<PeriodComponent>,
1458}
1459
1460fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1462 Decimal::from(delta.num_milliseconds())
1463 .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1464 .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1465}
1466
1467fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1469 let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1470 let Ok(millis) = i64::try_from(millis) else {
1471 return Err(warning::ErrorSetDeferred::with_warn(
1472 duration::Warning::Overflow.into(),
1473 ));
1474 };
1475 let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1476 return Err(warning::ErrorSetDeferred::with_warn(
1477 duration::Warning::Overflow.into(),
1478 ));
1479 };
1480 Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1481}
1482
1483impl StepSize {
1484 fn update(
1489 &mut self,
1490 period_index: usize,
1491 period: &PeriodNormalized,
1492 components: &ComponentSet,
1493 ) {
1494 if period.consumed.energy.is_some() {
1495 if let Some(energy) = components.energy.clone() {
1496 self.energy.replace(energy.into_period(period_index));
1497 }
1498 }
1499
1500 if period.consumed.duration_charging.is_some() {
1501 if let Some(duration) = components.duration_charging.clone() {
1502 self.duration_charging
1503 .replace(duration.into_period(period_index));
1504 }
1505 }
1506
1507 if period.consumed.duration_idle.is_some() {
1508 if let Some(duration) = components.duration_idle.clone() {
1509 self.duration_idle
1510 .replace(duration.into_period(period_index));
1511 }
1512 }
1513 }
1514
1515 fn apply_time(
1516 &self,
1517 periods: &mut [PeriodReport],
1518 total: TimeDelta,
1519 ) -> VerdictDeferred<TimeDelta> {
1520 let (Some(component), None) = (&self.duration_charging, &self.duration_idle) else {
1521 return Ok(total.into_caveat_deferred(warning::SetDeferred::new()));
1522 };
1523
1524 let PeriodComponent {
1525 period_index,
1526 component,
1527 } = component;
1528
1529 let Some(period) = periods.get_mut(*period_index) else {
1530 error!(period_index, "Invalid period index");
1531 return Err(warning::ErrorSetDeferred::with_warn(Warning::InternalError));
1532 };
1533 let Some(volume) = period.dimensions.duration_charging.billed_volume.as_mut() else {
1534 return Err(warning::ErrorSetDeferred::with_warn(
1535 Warning::DimensionShouldHaveVolume {
1536 dimension_name: "time",
1537 },
1538 ));
1539 };
1540
1541 duration_step_size(total, volume, component.step_size)
1542 }
1543
1544 fn apply_idle_time(
1545 &self,
1546 periods: &mut [PeriodReport],
1547 total: TimeDelta,
1548 ) -> VerdictDeferred<TimeDelta> {
1549 let warnings = warning::SetDeferred::new();
1550 let Some(component) = &self.duration_idle else {
1551 return Ok(total.into_caveat_deferred(warnings));
1552 };
1553
1554 let PeriodComponent {
1555 period_index,
1556 component,
1557 } = component;
1558
1559 let Some(period) = periods.get_mut(*period_index) else {
1560 error!(period_index, "Invalid period index");
1561 return warnings.bail(Warning::InternalError);
1562 };
1563 let Some(volume) = period.dimensions.duration_idle.billed_volume.as_mut() else {
1564 return warnings.bail(Warning::DimensionShouldHaveVolume {
1565 dimension_name: "parking_time",
1566 });
1567 };
1568
1569 duration_step_size(total, volume, component.step_size)
1570 }
1571
1572 fn apply_energy(
1573 &self,
1574 periods: &mut [PeriodReport],
1575 total_volume: Kwh,
1576 ) -> VerdictDeferred<Kwh> {
1577 let warnings = warning::SetDeferred::new();
1578 let Some(component) = &self.energy else {
1579 return Ok(total_volume.into_caveat_deferred(warnings));
1580 };
1581
1582 let PeriodComponent {
1583 period_index,
1584 component,
1585 } = component;
1586
1587 if component.step_size == 0 {
1588 return Ok(total_volume.into_caveat_deferred(warnings));
1589 }
1590
1591 let Some(period) = periods.get_mut(*period_index) else {
1592 error!(period_index, "Invalid period index");
1593 return warnings.bail(Warning::InternalError);
1594 };
1595 let step_size = Decimal::from(component.step_size);
1596
1597 let Some(period_billed_volume) = period.dimensions.energy.billed_volume.as_mut() else {
1598 return warnings.bail(Warning::DimensionShouldHaveVolume {
1599 dimension_name: "energy",
1600 });
1601 };
1602
1603 let Some(watt_hours) = total_volume.watt_hours().checked_div(step_size) else {
1604 return warnings.bail(duration::Warning::Overflow.into());
1605 };
1606
1607 let total_billed_volume = Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_size));
1608 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1609 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1610
1611 Ok(total_billed_volume.into_caveat_deferred(warnings))
1612 }
1613}
1614
1615fn duration_step_size(
1617 total_volume: TimeDelta,
1618 period_billed_volume: &mut TimeDelta,
1619 step_size: u64,
1620) -> VerdictDeferred<TimeDelta> {
1621 if step_size == 0 {
1622 return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1623 }
1624
1625 let total_seconds = delta_as_seconds_dec(total_volume);
1626 let step_size = Decimal::from(step_size);
1627
1628 let Some(x) = total_seconds.checked_div(step_size) else {
1629 return Err(warning::ErrorSetDeferred::with_warn(
1630 duration::Warning::Overflow.into(),
1631 ));
1632 };
1633 let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1634
1635 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1636 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1637
1638 Ok(total_billed_volume)
1639}
1640
1641fn parse_cdr<'buf>(cdr: &crate::cdr::Versioned<'buf>) -> Verdict<v221::cdr::WithTariffs<'buf>> {
1642 match cdr.version() {
1643 Version::V211 => {
1644 let cdr = v211::cdr::WithTariffs::from_json(cdr.as_element())?;
1645 Ok(cdr.map(v221::cdr::WithTariffs::from))
1646 }
1647 Version::V221 => v221::cdr::WithTariffs::from_json(cdr.as_element()),
1648 }
1649}