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_v211;
14
15#[cfg(test)]
16mod test_real_world_v221;
17
18#[cfg(test)]
19mod test_validate_cdr;
20
21mod tariff;
22mod v211;
23mod v221;
24
25use std::{borrow::Cow, collections::BTreeMap, fmt, ops::Range};
26
27use chrono::{DateTime, Datelike, 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, Hms},
35 from_warning_all, into_caveat_all,
36 json::{self, FromJson as _},
37 money,
38 number::{self, RoundDecimal},
39 string,
40 warning::{
41 self, GatherDeferredWarnings as _, GatherWarnings as _, IntoCaveat,
42 IntoCaveatDeferred as _, VerdictExt as _, WithElement as _,
43 },
44 weekday, Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, ParseError, Price,
45 SaturatingAdd as _, SaturatingSub as _, VatApplicable, Version, Versioned as _,
46};
47
48use tariff::Tariff;
49
50type Verdict<T> = crate::Verdict<T, Warning>;
51type VerdictDeferred<T> = warning::VerdictDeferred<T, Warning>;
52
53into_caveat_all!(PeriodNormalized, PeriodsReport, Report);
54
55#[derive(Debug)]
60struct PeriodNormalized {
61 consumed: Consumed,
63
64 start_snapshot: TotalsSnapshot,
66
67 end_snapshot: TotalsSnapshot,
69}
70
71#[derive(Clone, Debug)]
73#[cfg_attr(test, derive(Default))]
74pub(crate) struct Consumed {
75 pub current_max: Option<Ampere>,
77
78 pub current_min: Option<Ampere>,
80
81 pub duration_charging: Option<TimeDelta>,
83
84 pub duration_parking: Option<TimeDelta>,
86
87 pub energy: Option<Kwh>,
89
90 pub power_max: Option<Kw>,
92
93 pub power_min: Option<Kw>,
95}
96
97#[derive(Clone, Debug)]
99struct TotalsSnapshot {
100 date_time: DateTime<Utc>,
102
103 energy: Kwh,
105
106 local_timezone: Tz,
108
109 duration_charging: TimeDelta,
111
112 duration_total: TimeDelta,
114}
115
116impl TotalsSnapshot {
117 fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
119 Self {
120 date_time,
121 energy: Kwh::zero(),
122 local_timezone,
123 duration_charging: TimeDelta::zero(),
124 duration_total: TimeDelta::zero(),
125 }
126 }
127
128 fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
130 let duration = date_time.signed_duration_since(self.date_time);
131
132 let mut next = Self {
133 date_time,
134 energy: self.energy,
135 local_timezone: self.local_timezone,
136 duration_charging: self.duration_charging,
137 duration_total: self
138 .duration_total
139 .checked_add(&duration)
140 .unwrap_or(TimeDelta::MAX),
141 };
142
143 if let Some(duration) = consumed.duration_charging {
144 next.duration_charging = next
145 .duration_charging
146 .checked_add(&duration)
147 .unwrap_or(TimeDelta::MAX);
148 }
149
150 if let Some(energy) = consumed.energy {
151 next.energy = next.energy.saturating_add(energy);
152 }
153
154 next
155 }
156
157 fn local_time(&self) -> chrono::NaiveTime {
159 self.date_time.with_timezone(&self.local_timezone).time()
160 }
161
162 fn local_date(&self) -> chrono::NaiveDate {
164 self.date_time
165 .with_timezone(&self.local_timezone)
166 .date_naive()
167 }
168
169 fn local_weekday(&self) -> chrono::Weekday {
171 self.date_time.with_timezone(&self.local_timezone).weekday()
172 }
173}
174
175#[derive(Debug)]
178pub struct Report {
179 pub periods: Vec<PeriodReport>,
181
182 pub tariff_used: TariffOrigin,
184
185 pub tariff_reports: Vec<TariffReport>,
189
190 pub timezone: String,
192
193 pub billed_charging_time: Option<TimeDelta>,
196
197 pub billed_energy: Option<Kwh>,
199
200 pub billed_parking_time: Option<TimeDelta>,
202
203 pub total_charging_time: Option<TimeDelta>,
209
210 pub total_energy: Total<Kwh, Option<Kwh>>,
212
213 pub total_parking_time: Total<Option<TimeDelta>>,
215
216 pub total_time: Total<TimeDelta>,
218
219 pub total_cost: Total<Price, Option<Price>>,
222
223 pub total_energy_cost: Total<Option<Price>>,
225
226 pub total_fixed_cost: Total<Option<Price>>,
228
229 pub total_parking_cost: Total<Option<Price>>,
231
232 pub total_reservation_cost: Total<Option<Price>>,
234
235 pub total_time_cost: Total<Option<Price>>,
237}
238
239#[derive(Debug)]
241pub enum Warning {
242 Country(country::Warning),
243 Currency(currency::Warning),
244 DateTime(datetime::Warning),
245 Decode(json::decode::Warning),
246 Duration(duration::Warning),
247
248 CountryShouldBeAlpha2,
252
253 DimensionShouldHaveVolume {
255 dimension_name: &'static str,
256 },
257
258 FieldInvalidType {
260 expected_type: json::ValueKind,
262 },
263
264 FieldInvalidValue {
266 value: String,
268
269 message: Cow<'static, str>,
271 },
272
273 FieldRequired {
275 field_name: Cow<'static, str>,
276 },
277
278 InternalError,
282
283 Money(money::Warning),
284
285 NoPeriods,
287
288 NoValidTariff,
298
299 Number(number::Warning),
300
301 Parse(ParseError),
303
304 PeriodsOutsideStartEndDateTime {
307 cdr_range: Range<DateTime<Utc>>,
308 period_range: PeriodRange,
309 },
310
311 String(string::Warning),
312
313 Tariff(crate::tariff::Warning),
316
317 Weekday(weekday::Warning),
318}
319
320impl Warning {
321 fn field_invalid_value(
323 value: impl Into<String>,
324 message: impl Into<Cow<'static, str>>,
325 ) -> Self {
326 Warning::FieldInvalidValue {
327 value: value.into(),
328 message: message.into(),
329 }
330 }
331}
332
333impl fmt::Display for Warning {
334 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335 match self {
336 Self::Country(warning_kind) => write!(f, "{warning_kind}"),
337 Self::CountryShouldBeAlpha2 => {
338 f.write_str("The `$.country` field should be an alpha-2 country code.")
339 }
340 Self::Currency(warning_kind) => write!(f, "{warning_kind}"),
341 Self::DateTime(warning_kind) => write!(f, "{warning_kind}"),
342 Self::Decode(warning_kind) => write!(f, "{warning_kind}"),
343 Self::DimensionShouldHaveVolume { dimension_name } => {
344 write!(f, "Dimension `{dimension_name}` should have volume")
345 }
346 Self::Duration(warning_kind) => write!(f, "{warning_kind}"),
347 Self::FieldInvalidType { expected_type } => {
348 write!(f, "Field has invalid type. Expected type `{expected_type}`")
349 }
350 Self::FieldInvalidValue { value, message } => {
351 write!(f, "Field has invalid value `{value}`: {message}")
352 }
353 Self::FieldRequired { field_name } => {
354 write!(f, "Field is required: `{field_name}`")
355 }
356 Self::InternalError => f.write_str("Internal error"),
357 Self::Money(warning_kind) => write!(f, "{warning_kind}"),
358 Self::NoPeriods => f.write_str("The CDR has no charging periods"),
359 Self::NoValidTariff => {
360 f.write_str("No valid tariff has been found in the list of provided tariffs")
361 }
362 Self::Number(warning_kind) => write!(f, "{warning_kind}"),
363 Self::Parse(err) => {
364 write!(f, "{err}")
365 }
366 Self::PeriodsOutsideStartEndDateTime {
367 cdr_range: Range { start, end },
368 period_range,
369 } => {
370 write!(
371 f,
372 "The CDR's charging period time range is not contained within the `start_date_time` \
373 and `end_date_time`; cdr_range: {start}-{end}, period_range: {period_range}",
374 )
375 }
376 Self::String(warning_kind) => write!(f, "{warning_kind}"),
377 Self::Tariff(warnings) => {
378 write!(f, "Tariff warnings: {warnings:?}")
379 }
380 Self::Weekday(warning_kind) => write!(f, "{warning_kind}"),
381 }
382 }
383}
384
385impl crate::Warning for Warning {
386 fn id(&self) -> warning::Id {
387 match self {
388 Self::Country(kind) => kind.id(),
389 Self::CountryShouldBeAlpha2 => warning::Id::from_static("country_should_be_alpha_2"),
390 Self::Currency(kind) => kind.id(),
391 Self::DateTime(kind) => kind.id(),
392 Self::Decode(kind) => kind.id(),
393 Self::DimensionShouldHaveVolume { dimension_name } => {
394 warning::Id::from_string(format!("dimension_should_have_volume({dimension_name})"))
395 }
396 Self::Duration(kind) => kind.id(),
397 Self::FieldInvalidType { .. } => warning::Id::from_static("field_invalid_type"),
398 Self::FieldInvalidValue { .. } => warning::Id::from_static("field_invalid_value"),
399 Self::FieldRequired { field_name } => {
400 warning::Id::from_string(format!("field_required({field_name})"))
401 }
402 Self::InternalError => warning::Id::from_static("internal_error"),
403 Self::Money(kind) => kind.id(),
404 Self::NoPeriods => warning::Id::from_static("no_periods"),
405 Self::NoValidTariff => warning::Id::from_static("no_valid_tariff"),
406 Self::Number(kind) => kind.id(),
407 Self::Parse(ParseError { object: _, kind }) => kind.id(),
408 Self::PeriodsOutsideStartEndDateTime { .. } => {
409 warning::Id::from_static("periods_outside_start_end_date_time")
410 }
411 Self::String(kind) => kind.id(),
412 Self::Tariff(kind) => kind.id(),
413 Self::Weekday(kind) => kind.id(),
414 }
415 }
416}
417
418from_warning_all!(
419 country::Warning => Warning::Country,
420 currency::Warning => Warning::Currency,
421 datetime::Warning => Warning::DateTime,
422 duration::Warning => Warning::Duration,
423 json::decode::Warning => Warning::Decode,
424 money::Warning => Warning::Money,
425 number::Warning => Warning::Number,
426 string::Warning => Warning::String,
427 crate::tariff::Warning => Warning::Tariff,
428 weekday::Warning => Warning::Weekday
429);
430
431#[derive(Debug)]
433pub struct TariffReport {
434 pub origin: TariffOrigin,
436
437 pub warnings: BTreeMap<warning::Path, Vec<crate::tariff::Warning>>,
441}
442
443#[derive(Clone, Debug)]
445pub struct TariffOrigin {
446 pub index: usize,
448
449 pub id: String,
451
452 pub currency: currency::Code,
454}
455
456#[derive(Debug)]
458pub(crate) struct Period {
459 pub start_date_time: DateTime<Utc>,
461
462 pub consumed: Consumed,
464}
465
466#[derive(Debug)]
468pub struct Dimensions {
469 pub energy: Dimension<Kwh>,
471
472 pub flat: Dimension<()>,
474
475 pub duration_charging: Dimension<TimeDelta>,
477
478 pub duration_parking: Dimension<TimeDelta>,
480}
481
482impl Dimensions {
483 fn new(components: ComponentSet, consumed: &Consumed) -> Self {
484 let ComponentSet {
485 energy: energy_price,
486 flat: flat_price,
487 duration_charging: duration_charging_price,
488 duration_parking: duration_parking_price,
489 } = components;
490
491 let Consumed {
492 duration_charging,
493 duration_parking,
494 energy,
495 current_max: _,
496 current_min: _,
497 power_max: _,
498 power_min: _,
499 } = consumed;
500
501 Self {
502 energy: Dimension {
503 price: energy_price,
504 volume: *energy,
505 billed_volume: *energy,
506 },
507 flat: Dimension {
508 price: flat_price,
509 volume: Some(()),
510 billed_volume: Some(()),
511 },
512 duration_charging: Dimension {
513 price: duration_charging_price,
514 volume: *duration_charging,
515 billed_volume: *duration_charging,
516 },
517 duration_parking: Dimension {
518 price: duration_parking_price,
519 volume: *duration_parking,
520 billed_volume: *duration_parking,
521 },
522 }
523 }
524}
525
526#[derive(Debug)]
527pub struct Dimension<V> {
529 pub price: Option<Component>,
533
534 pub volume: Option<V>,
538
539 pub billed_volume: Option<V>,
547}
548
549impl<V: Cost> Dimension<V> {
550 pub fn cost(&self) -> Option<Price> {
552 let (Some(volume), Some(price_component)) = (&self.billed_volume, &self.price) else {
553 return None;
554 };
555
556 let excl_vat = volume.cost(price_component.price);
557
558 let incl_vat = match price_component.vat {
559 VatApplicable::Applicable(vat) => Some(excl_vat.apply_vat(vat)),
560 VatApplicable::Inapplicable => Some(excl_vat),
561 VatApplicable::Unknown => None,
562 };
563
564 Some(Price { excl_vat, incl_vat })
565 }
566}
567
568#[derive(Debug)]
573pub struct ComponentSet {
574 pub energy: Option<Component>,
576
577 pub flat: Option<Component>,
579
580 pub duration_charging: Option<Component>,
582
583 pub duration_parking: Option<Component>,
585}
586
587impl ComponentSet {
588 fn has_all_components(&self) -> bool {
590 let Self {
591 energy,
592 flat,
593 duration_charging,
594 duration_parking,
595 } = self;
596
597 flat.is_some()
598 && energy.is_some()
599 && duration_parking.is_some()
600 && duration_charging.is_some()
601 }
602}
603
604#[derive(Clone, Debug)]
609pub struct Component {
610 pub tariff_element_index: usize,
612
613 pub price: Money,
615
616 pub vat: VatApplicable,
619
620 pub step_size: u64,
628}
629
630impl Component {
631 fn new(component: &crate::tariff::v221::PriceComponent, tariff_element_index: usize) -> Self {
632 let crate::tariff::v221::PriceComponent {
633 price,
634 vat,
635 step_size,
636 dimension_type: _,
637 } = component;
638
639 Self {
640 tariff_element_index,
641 price: *price,
642 vat: *vat,
643 step_size: *step_size,
644 }
645 }
646}
647
648#[derive(Debug)]
662pub struct Total<TCdr, TCalc = TCdr> {
663 pub cdr: TCdr,
665
666 pub calculated: TCalc,
668}
669
670#[derive(Debug)]
672pub enum PeriodRange {
673 Many(Range<DateTime<Utc>>),
676
677 Single(DateTime<Utc>),
679}
680
681impl fmt::Display for PeriodRange {
682 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
683 match self {
684 PeriodRange::Many(Range { start, end }) => write!(f, "{start}-{end}"),
685 PeriodRange::Single(date_time) => write!(f, "{date_time}"),
686 }
687 }
688}
689
690#[derive(Debug)]
694pub enum TariffSource<'buf> {
695 UseCdr,
697
698 Override(Vec<crate::tariff::Versioned<'buf>>),
700}
701
702impl<'buf> TariffSource<'buf> {
703 pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
705 Self::Override(vec![tariff])
706 }
707}
708
709#[instrument(skip_all)]
710pub(super) fn cdr(
711 cdr_elem: &crate::cdr::Versioned<'_>,
712 tariff_source: TariffSource<'_>,
713 timezone: Tz,
714) -> Verdict<Report> {
715 let cdr = parse_cdr(cdr_elem)?;
716
717 match tariff_source {
718 TariffSource::UseCdr => {
719 let (v221::cdr::WithTariffs { cdr, tariffs }, warnings) = cdr.into_parts();
720 debug!("Using tariffs from CDR");
721 let tariffs = tariffs
722 .iter()
723 .map(|elem| {
724 let tariff = crate::tariff::v211::Tariff::from_json(elem);
725 tariff.map_caveat(crate::tariff::v221::Tariff::from)
726 })
727 .collect::<Result<Vec<_>, _>>()?;
728
729 let cdr = cdr.into_caveat(warnings);
730
731 Ok(price_v221_cdr_with_tariffs(
732 cdr_elem, cdr, tariffs, timezone,
733 )?)
734 }
735 TariffSource::Override(tariffs) => {
736 let cdr = cdr.map(v221::cdr::WithTariffs::discard_tariffs);
737
738 debug!("Using override tariffs");
739 let tariffs = tariffs
740 .iter()
741 .map(tariff::parse)
742 .collect::<Result<Vec<_>, _>>()?;
743
744 Ok(price_v221_cdr_with_tariffs(
745 cdr_elem, cdr, tariffs, timezone,
746 )?)
747 }
748 }
749}
750
751fn price_v221_cdr_with_tariffs(
758 cdr_elem: &crate::cdr::Versioned<'_>,
759 cdr: Caveat<v221::Cdr, Warning>,
760 tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
761 timezone: Tz,
762) -> Verdict<Report> {
763 debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
764 let (cdr, mut warnings) = cdr.into_parts();
765 let v221::Cdr {
766 start_date_time,
767 end_date_time,
768 charging_periods,
769 totals: cdr_totals,
770 } = cdr;
771
772 let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
777 .into_iter()
778 .enumerate()
779 .map(|(index, tariff)| {
780 let (tariff, warnings) = tariff.into_parts();
781 (
782 TariffReport {
783 origin: TariffOrigin {
784 index,
785 id: tariff.id.to_string(),
786 currency: tariff.currency,
787 },
788 warnings: warnings.into_path_map(),
789 },
790 tariff,
791 )
792 })
793 .unzip();
794
795 debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
796
797 let tariffs_normalized = tariff::normalize_all(&tariffs);
798 let Some((tariff_index, tariff)) =
799 tariff::find_first_active(tariffs_normalized, start_date_time)
800 else {
801 return warnings.bail(Warning::NoValidTariff, cdr_elem.as_element());
802 };
803
804 debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
805 debug!(%timezone, "Found timezone");
806 let periods = charging_periods
808 .into_iter()
809 .map(Period::try_from)
810 .collect::<Result<Vec<_>, _>>()
811 .map_err(|err| warning::ErrorSet::with_warn(Warning::Parse(err), cdr_elem.as_element()))?;
812
813 let periods = normalize_periods(periods, end_date_time, timezone);
814 let price_cdr_report = price_periods(&periods, &tariff)
815 .with_element(cdr_elem.as_element())?
816 .gather_warnings_into(&mut warnings);
817
818 let report = generate_report(
819 &cdr_totals,
820 timezone,
821 tariff_reports,
822 price_cdr_report,
823 TariffOrigin {
824 index: tariff_index,
825 id: tariff.id().to_string(),
826 currency: tariff.currency(),
827 },
828 );
829
830 Ok(report.into_caveat(warnings))
831}
832
833pub(crate) fn periods(
835 end_date_time: DateTime<Utc>,
836 timezone: Tz,
837 tariff_elem: &crate::tariff::v221::Tariff<'_>,
838 mut periods: Vec<Period>,
839) -> VerdictDeferred<PeriodsReport> {
840 periods.sort_by_key(|p| p.start_date_time);
843 let tariff = Tariff::from_v221(tariff_elem);
844 let periods = normalize_periods(periods, end_date_time, timezone);
845 price_periods(&periods, &tariff)
846}
847
848fn normalize_periods(
849 periods: Vec<Period>,
850 end_date_time: DateTime<Utc>,
851 local_timezone: Tz,
852) -> Vec<PeriodNormalized> {
853 debug!("Normalizing CDR periods");
854
855 let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
857
858 let end_dates = {
860 let mut end_dates = periods
861 .iter()
862 .skip(1)
863 .map(|p| p.start_date_time)
864 .collect::<Vec<_>>();
865
866 end_dates.push(end_date_time);
868 end_dates
869 };
870
871 let periods = periods
872 .into_iter()
873 .zip(end_dates)
874 .enumerate()
875 .map(|(index, (period, end_date_time))| {
876 trace!(index, "processing\n{period:#?}");
877 let Period {
878 start_date_time,
879 consumed,
880 } = period;
881
882 let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
883 let start_snapshot = prev_end_snapshot;
884 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
885
886 let period = PeriodNormalized {
887 consumed,
888 start_snapshot,
889 end_snapshot,
890 };
891 trace!("Adding new period based on the last added\n{period:#?}");
892 period
893 } else {
894 let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
895 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
896
897 let period = PeriodNormalized {
898 consumed,
899 start_snapshot,
900 end_snapshot,
901 };
902 trace!("Adding new period\n{period:#?}");
903 period
904 };
905
906 previous_end_snapshot.replace(period.end_snapshot.clone());
907 period
908 })
909 .collect::<Vec<_>>();
910
911 periods
912}
913
914fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
916 debug!(count = periods.len(), "Pricing CDR periods");
917
918 if tracing::enabled!(tracing::Level::TRACE) {
919 trace!("# CDR period list:");
920 for period in periods {
921 trace!("{period:#?}");
922 }
923 }
924
925 let period_totals = period_totals(periods, tariff);
926 let (billed, warnings) = period_totals.calculate_billed()?.into_parts();
927 let (billable, periods, totals) = billed;
928 let total_costs = total_costs(&periods, tariff);
929 let report = PeriodsReport {
930 billable,
931 periods,
932 totals,
933 total_costs,
934 };
935
936 Ok(report.into_caveat_deferred(warnings))
937}
938
939pub(crate) struct PeriodsReport {
941 pub billable: Billable,
943
944 pub periods: Vec<PeriodReport>,
946
947 pub totals: Totals,
949
950 pub total_costs: TotalCosts,
952}
953
954#[derive(Debug)]
960pub struct PeriodReport {
961 pub start_date_time: DateTime<Utc>,
963
964 pub end_date_time: DateTime<Utc>,
966
967 pub dimensions: Dimensions,
969}
970
971impl PeriodReport {
972 fn new(period: &PeriodNormalized, dimensions: Dimensions) -> Self {
973 Self {
974 start_date_time: period.start_snapshot.date_time,
975 end_date_time: period.end_snapshot.date_time,
976 dimensions,
977 }
978 }
979
980 pub fn cost(&self) -> Option<Price> {
982 [
983 self.dimensions.duration_charging.cost(),
984 self.dimensions.duration_parking.cost(),
985 self.dimensions.flat.cost(),
986 self.dimensions.energy.cost(),
987 ]
988 .into_iter()
989 .fold(None, |accum, next| {
990 if accum.is_none() && next.is_none() {
991 None
992 } else {
993 Some(
994 accum
995 .unwrap_or_default()
996 .saturating_add(next.unwrap_or_default()),
997 )
998 }
999 })
1000 }
1001}
1002
1003#[derive(Debug)]
1005struct PeriodTotals {
1006 periods: Vec<PeriodReport>,
1008
1009 step_size: StepSize,
1011
1012 totals: Totals,
1014}
1015
1016#[derive(Debug, Default)]
1018pub(crate) struct Totals {
1019 pub energy: Option<Kwh>,
1021
1022 pub duration_charging: Option<TimeDelta>,
1026
1027 pub duration_parking: Option<TimeDelta>,
1031}
1032
1033impl PeriodTotals {
1034 fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
1038 let mut warnings = warning::SetDeferred::new();
1039 let Self {
1040 mut periods,
1041 step_size,
1042 totals,
1043 } = self;
1044 let charging_time = totals
1045 .duration_charging
1046 .map(|dt| step_size.apply_time(&mut periods, dt))
1047 .transpose()?
1048 .gather_deferred_warnings_into(&mut warnings);
1049 let energy = totals
1050 .energy
1051 .map(|kwh| step_size.apply_energy(&mut periods, kwh))
1052 .transpose()?
1053 .gather_deferred_warnings_into(&mut warnings);
1054 let parking_time = totals
1055 .duration_parking
1056 .map(|dt| step_size.apply_parking_time(&mut periods, dt))
1057 .transpose()?
1058 .gather_deferred_warnings_into(&mut warnings);
1059 let billed = Billable {
1060 charging_time,
1061 energy,
1062 parking_time,
1063 };
1064 Ok((billed, periods, totals).into_caveat_deferred(warnings))
1065 }
1066}
1067
1068#[derive(Debug)]
1070pub(crate) struct Billable {
1071 charging_time: Option<TimeDelta>,
1073
1074 energy: Option<Kwh>,
1076
1077 parking_time: Option<TimeDelta>,
1079}
1080
1081fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1084 let mut has_flat_fee = false;
1085 let mut step_size = StepSize::new();
1086 let mut totals = Totals::default();
1087
1088 debug!(
1089 tariff_id = tariff.id(),
1090 period_count = periods.len(),
1091 "Accumulating dimension totals for each period"
1092 );
1093
1094 let periods = periods
1095 .iter()
1096 .enumerate()
1097 .map(|(index, period)| {
1098 let mut component_set = tariff.active_components(period);
1099 trace!(
1100 index,
1101 "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1102 );
1103
1104 if component_set.flat.is_some() {
1105 if has_flat_fee {
1106 component_set.flat = None;
1107 } else {
1108 has_flat_fee = true;
1109 }
1110 }
1111
1112 step_size.update(index, &component_set, period);
1113
1114 trace!(period_index = index, "Step size updated\n{step_size:#?}");
1115
1116 let dimensions = Dimensions::new(component_set, &period.consumed);
1117
1118 trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1119
1120 if let Some(dt) = dimensions.duration_charging.volume {
1121 let acc = totals.duration_charging.get_or_insert_default();
1122 *acc = acc.saturating_add(dt);
1123 }
1124
1125 if let Some(kwh) = dimensions.energy.volume {
1126 let acc = totals.energy.get_or_insert_default();
1127 *acc = acc.saturating_add(kwh);
1128 }
1129
1130 if let Some(dt) = dimensions.duration_parking.volume {
1131 let acc = totals.duration_parking.get_or_insert_default();
1132 *acc = acc.saturating_add(dt);
1133 }
1134
1135 trace!(period_index = index, ?totals, "Update totals");
1136
1137 PeriodReport::new(period, dimensions)
1138 })
1139 .collect::<Vec<_>>();
1140
1141 PeriodTotals {
1142 periods,
1143 step_size,
1144 totals,
1145 }
1146}
1147
1148#[derive(Debug, Default)]
1150pub(crate) struct TotalCosts {
1151 pub energy: Option<Price>,
1153
1154 pub fixed: Option<Price>,
1156
1157 pub duration_charging: Option<Price>,
1159
1160 pub duration_parking: Option<Price>,
1162}
1163
1164impl TotalCosts {
1165 pub(crate) fn total(&self) -> Option<Price> {
1169 let Self {
1170 energy,
1171 fixed,
1172 duration_charging,
1173 duration_parking,
1174 } = self;
1175 debug!(
1176 energy = %DisplayOption(*energy),
1177 fixed = %DisplayOption(*fixed),
1178 duration_charging = %DisplayOption(*duration_charging),
1179 duration_parking = %DisplayOption(*duration_parking),
1180 "Calculating total costs."
1181 );
1182 [energy, fixed, duration_charging, duration_parking]
1183 .into_iter()
1184 .fold(None, |accum: Option<Price>, next| match (accum, next) {
1185 (None, None) => None,
1186 _ => Some(
1187 accum
1188 .unwrap_or_default()
1189 .saturating_add(next.unwrap_or_default()),
1190 ),
1191 })
1192 }
1193}
1194
1195fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1197 let mut total_costs = TotalCosts::default();
1198
1199 debug!(
1200 tariff_id = tariff.id(),
1201 period_count = periods.len(),
1202 "Accumulating dimension costs for each period"
1203 );
1204 for (index, period) in periods.iter().enumerate() {
1205 let dimensions = &period.dimensions;
1206
1207 trace!(period_index = index, "Processing period");
1208
1209 let energy_cost = dimensions.energy.cost();
1210 let fixed_cost = dimensions.flat.cost();
1211 let duration_charging_cost = dimensions.duration_charging.cost();
1212 let duration_parking_cost = dimensions.duration_parking.cost();
1213
1214 trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1215 trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1216 trace!(?total_costs.duration_parking, ?duration_parking_cost, "Parking cost");
1217 trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1218
1219 total_costs.energy = match (total_costs.energy, energy_cost) {
1220 (None, None) => None,
1221 (total, period) => Some(
1222 total
1223 .unwrap_or_default()
1224 .saturating_add(period.unwrap_or_default()),
1225 ),
1226 };
1227
1228 total_costs.duration_charging =
1229 match (total_costs.duration_charging, duration_charging_cost) {
1230 (None, None) => None,
1231 (total, period) => Some(
1232 total
1233 .unwrap_or_default()
1234 .saturating_add(period.unwrap_or_default()),
1235 ),
1236 };
1237
1238 total_costs.duration_parking = match (total_costs.duration_parking, duration_parking_cost) {
1239 (None, None) => None,
1240 (total, period) => Some(
1241 total
1242 .unwrap_or_default()
1243 .saturating_add(period.unwrap_or_default()),
1244 ),
1245 };
1246
1247 total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1248 (None, None) => None,
1249 (total, period) => Some(
1250 total
1251 .unwrap_or_default()
1252 .saturating_add(period.unwrap_or_default()),
1253 ),
1254 };
1255
1256 trace!(period_index = index, ?total_costs, "Update totals");
1257 }
1258
1259 total_costs
1260}
1261
1262fn generate_report(
1263 cdr_totals: &v221::cdr::Totals,
1264 timezone: Tz,
1265 tariff_reports: Vec<TariffReport>,
1266 price_periods_report: PeriodsReport,
1267 tariff_used: TariffOrigin,
1268) -> Report {
1269 let PeriodsReport {
1270 billable,
1271 periods,
1272 totals,
1273 total_costs,
1274 } = price_periods_report;
1275 trace!("Update billed totals {billable:#?}");
1276
1277 let total_cost = total_costs.total();
1278
1279 debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1280
1281 let total_time = {
1282 debug!(
1283 period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1284 period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1285 "Calculating `total_time`"
1286 );
1287
1288 periods
1289 .first()
1290 .zip(periods.last())
1291 .map(|(first, last)| {
1292 last.end_date_time
1293 .signed_duration_since(first.start_date_time)
1294 })
1295 .unwrap_or_default()
1296 };
1297 debug!(total_time = %Hms(total_time));
1298
1299 let report = Report {
1300 periods,
1301 tariff_used,
1302 timezone: timezone.to_string(),
1303 billed_parking_time: billable.parking_time,
1304 billed_energy: billable.energy.round_to_ocpi_scale(),
1305 billed_charging_time: billable.charging_time,
1306 tariff_reports,
1307 total_charging_time: totals.duration_charging,
1308 total_cost: Total {
1309 cdr: cdr_totals.cost.round_to_ocpi_scale(),
1310 calculated: total_cost.round_to_ocpi_scale(),
1311 },
1312 total_time_cost: Total {
1313 cdr: cdr_totals.time_cost.round_to_ocpi_scale(),
1314 calculated: total_costs.duration_charging.round_to_ocpi_scale(),
1315 },
1316 total_time: Total {
1317 cdr: cdr_totals.time,
1318 calculated: total_time,
1319 },
1320 total_parking_cost: Total {
1321 cdr: cdr_totals.parking_cost.round_to_ocpi_scale(),
1322 calculated: total_costs.duration_parking.round_to_ocpi_scale(),
1323 },
1324 total_parking_time: Total {
1325 cdr: cdr_totals.parking_time,
1326 calculated: totals.duration_parking,
1327 },
1328 total_energy_cost: Total {
1329 cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
1330 calculated: total_costs.energy.round_to_ocpi_scale(),
1331 },
1332 total_energy: Total {
1333 cdr: cdr_totals.energy.round_to_ocpi_scale(),
1334 calculated: totals.energy.round_to_ocpi_scale(),
1335 },
1336 total_fixed_cost: Total {
1337 cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
1338 calculated: total_costs.fixed.round_to_ocpi_scale(),
1339 },
1340 total_reservation_cost: Total {
1341 cdr: cdr_totals.reservation_cost.round_to_ocpi_scale(),
1342 calculated: None,
1343 },
1344 };
1345
1346 trace!("{report:#?}");
1347
1348 report
1349}
1350
1351#[derive(Debug)]
1352struct StepSize {
1353 charging_time: Option<(usize, Component)>,
1354 parking_time: Option<(usize, Component)>,
1355 energy: Option<(usize, Component)>,
1356}
1357
1358fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1360 Decimal::from(delta.num_milliseconds())
1361 .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1362 .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1363}
1364
1365fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1367 let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1368 let Ok(millis) = i64::try_from(millis) else {
1369 return Err(warning::ErrorSetDeferred::with_warn(
1370 duration::Warning::Overflow.into(),
1371 ));
1372 };
1373 let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1374 return Err(warning::ErrorSetDeferred::with_warn(
1375 duration::Warning::Overflow.into(),
1376 ));
1377 };
1378 Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1379}
1380
1381impl StepSize {
1382 fn new() -> Self {
1383 Self {
1384 charging_time: None,
1385 parking_time: None,
1386 energy: None,
1387 }
1388 }
1389
1390 fn update(&mut self, index: usize, components: &ComponentSet, period: &PeriodNormalized) {
1391 if period.consumed.energy.is_some() {
1392 if let Some(energy) = components.energy.clone() {
1393 self.energy = Some((index, energy));
1394 }
1395 }
1396
1397 if period.consumed.duration_charging.is_some() {
1398 if let Some(time) = components.duration_charging.clone() {
1399 self.charging_time = Some((index, time));
1400 }
1401 }
1402
1403 if period.consumed.duration_parking.is_some() {
1404 if let Some(parking) = components.duration_parking.clone() {
1405 self.parking_time = Some((index, parking));
1406 }
1407 }
1408 }
1409
1410 fn duration_step_size(
1411 total_volume: TimeDelta,
1412 period_billed_volume: &mut TimeDelta,
1413 step_size: u64,
1414 ) -> VerdictDeferred<TimeDelta> {
1415 if step_size == 0 {
1416 return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1417 }
1418
1419 let total_seconds = delta_as_seconds_dec(total_volume);
1420 let step_size = Decimal::from(step_size);
1421
1422 let Some(x) = total_seconds.checked_div(step_size) else {
1423 return Err(warning::ErrorSetDeferred::with_warn(
1424 duration::Warning::Overflow.into(),
1425 ));
1426 };
1427 let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1428
1429 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1430 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1431
1432 Ok(total_billed_volume)
1433 }
1434
1435 fn apply_time(
1436 &self,
1437 periods: &mut [PeriodReport],
1438 total: TimeDelta,
1439 ) -> VerdictDeferred<TimeDelta> {
1440 let (Some((time_index, price)), None) = (&self.charging_time, &self.parking_time) else {
1441 return Ok(total.into_caveat_deferred(warning::SetDeferred::new()));
1442 };
1443
1444 let Some(period) = periods.get_mut(*time_index) else {
1445 error!(time_index, "Invalid period index");
1446 return Err(warning::ErrorSetDeferred::with_warn(Warning::InternalError));
1447 };
1448 let Some(volume) = period.dimensions.duration_charging.billed_volume.as_mut() else {
1449 return Err(warning::ErrorSetDeferred::with_warn(
1450 Warning::DimensionShouldHaveVolume {
1451 dimension_name: "time",
1452 },
1453 ));
1454 };
1455
1456 Self::duration_step_size(total, volume, price.step_size)
1457 }
1458
1459 fn apply_parking_time(
1460 &self,
1461 periods: &mut [PeriodReport],
1462 total: TimeDelta,
1463 ) -> VerdictDeferred<TimeDelta> {
1464 let warnings = warning::SetDeferred::new();
1465 let Some((parking_index, price)) = &self.parking_time else {
1466 return Ok(total.into_caveat_deferred(warnings));
1467 };
1468
1469 let Some(period) = periods.get_mut(*parking_index) else {
1470 error!(parking_index, "Invalid period index");
1471 return warnings.bail(Warning::InternalError);
1472 };
1473 let Some(volume) = period.dimensions.duration_parking.billed_volume.as_mut() else {
1474 return warnings.bail(Warning::DimensionShouldHaveVolume {
1475 dimension_name: "parking_time",
1476 });
1477 };
1478
1479 Self::duration_step_size(total, volume, price.step_size)
1480 }
1481
1482 fn apply_energy(
1483 &self,
1484 periods: &mut [PeriodReport],
1485 total_volume: Kwh,
1486 ) -> VerdictDeferred<Kwh> {
1487 let warnings = warning::SetDeferred::new();
1488 let Some((energy_index, price)) = &self.energy else {
1489 return Ok(total_volume.into_caveat_deferred(warnings));
1490 };
1491
1492 if price.step_size == 0 {
1493 return Ok(total_volume.into_caveat_deferred(warnings));
1494 }
1495
1496 let Some(period) = periods.get_mut(*energy_index) else {
1497 error!(energy_index, "Invalid period index");
1498 return warnings.bail(Warning::InternalError);
1499 };
1500 let step_size = Decimal::from(price.step_size);
1501
1502 let Some(period_billed_volume) = period.dimensions.energy.billed_volume.as_mut() else {
1503 return warnings.bail(Warning::DimensionShouldHaveVolume {
1504 dimension_name: "energy",
1505 });
1506 };
1507
1508 let Some(watt_hours) = total_volume.watt_hours().checked_div(step_size) else {
1509 return warnings.bail(duration::Warning::Overflow.into());
1510 };
1511
1512 let total_billed_volume = Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_size));
1513 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1514 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1515
1516 Ok(total_billed_volume.into_caveat_deferred(warnings))
1517 }
1518}
1519
1520fn parse_cdr<'caller: 'buf, 'buf>(
1521 cdr: &'caller crate::cdr::Versioned<'buf>,
1522) -> Verdict<v221::cdr::WithTariffs<'buf>> {
1523 match cdr.version() {
1524 Version::V211 => {
1525 let cdr = v211::cdr::WithTariffs::from_json(cdr.as_element())?;
1526 Ok(cdr.map(v221::cdr::WithTariffs::from))
1527 }
1528 Version::V221 => v221::cdr::WithTariffs::from_json(cdr.as_element()),
1529 }
1530}