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_ids_path_map;
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, AsHms, Hms},
35 enumeration, from_warning_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 Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, ParseError, Price, SaturatingAdd as _,
45 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
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_parking: 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(
105 "duration_parking",
106 &self.duration_parking.map(|dt| dt.as_hms()),
107 )
108 .field("energy", &self.energy)
109 .field("power_max", &self.power_max)
110 .field("power_min", &self.power_min)
111 .finish()
112 }
113}
114
115#[derive(Clone)]
117struct TotalsSnapshot {
118 date_time: DateTime<Utc>,
120
121 energy: Kwh,
123
124 local_timezone: Tz,
126
127 duration_charging: TimeDelta,
129
130 duration_total: TimeDelta,
132}
133
134impl fmt::Debug for TotalsSnapshot {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 f.debug_struct("TotalsSnapshot")
137 .field("date_time", &self.date_time)
138 .field("energy", &self.energy)
139 .field("local_timezone", &self.local_timezone)
140 .field("duration_charging", &self.duration_charging.as_hms())
141 .field("duration_total", &self.duration_total.as_hms())
142 .finish()
143 }
144}
145
146impl TotalsSnapshot {
147 fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
149 Self {
150 date_time,
151 energy: Kwh::zero(),
152 local_timezone,
153 duration_charging: TimeDelta::zero(),
154 duration_total: TimeDelta::zero(),
155 }
156 }
157
158 fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
160 let duration = date_time.signed_duration_since(self.date_time);
161
162 let mut next = Self {
163 date_time,
164 energy: self.energy,
165 local_timezone: self.local_timezone,
166 duration_charging: self.duration_charging,
167 duration_total: self.duration_total.saturating_add(duration),
168 };
169
170 if let Some(duration) = consumed.duration_charging {
171 next.duration_charging = next.duration_charging.saturating_add(duration);
172 }
173
174 if let Some(energy) = consumed.energy {
175 next.energy = next.energy.saturating_add(energy);
176 }
177
178 next
179 }
180
181 fn local_time(&self) -> chrono::NaiveTime {
183 self.date_time.with_timezone(&self.local_timezone).time()
184 }
185
186 fn local_date(&self) -> chrono::NaiveDate {
188 self.date_time
189 .with_timezone(&self.local_timezone)
190 .date_naive()
191 }
192
193 fn local_weekday(&self) -> chrono::Weekday {
195 self.date_time.with_timezone(&self.local_timezone).weekday()
196 }
197}
198
199pub struct Report {
202 pub periods: Vec<PeriodReport>,
204
205 pub tariff_used: TariffOrigin,
207
208 pub tariff_reports: Vec<TariffReport>,
212
213 pub timezone: String,
215
216 pub billed_charging_time: Option<TimeDelta>,
219
220 pub billed_energy: Option<Kwh>,
222
223 pub billed_parking_time: Option<TimeDelta>,
225
226 pub total_charging_time: Option<TimeDelta>,
232
233 pub total_energy: Total<Kwh, Option<Kwh>>,
235
236 pub total_parking_time: Total<Option<TimeDelta>>,
238
239 pub total_time: Total<TimeDelta>,
241
242 pub total_cost: Total<Price, Option<Price>>,
245
246 pub total_energy_cost: Total<Option<Price>>,
248
249 pub total_fixed_cost: Total<Option<Price>>,
251
252 pub total_parking_cost: Total<Option<Price>>,
254
255 pub total_reservation_cost: Total<Option<Price>>,
257
258 pub total_time_cost: Total<Option<Price>>,
260}
261
262impl fmt::Debug for Report {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 f.debug_struct("Report")
265 .field("periods", &self.periods)
266 .field("tariff_used", &self.tariff_used)
267 .field("tariff_reports", &self.tariff_reports)
268 .field("timezone", &self.timezone)
269 .field(
270 "billed_charging_time",
271 &self.billed_charging_time.map(|dt| dt.as_hms()),
272 )
273 .field("billed_energy", &self.billed_energy)
274 .field(
275 "billed_parking_time",
276 &self.billed_parking_time.map(|dt| dt.as_hms()),
277 )
278 .field(
279 "total_charging_time",
280 &self.total_charging_time.map(|dt| dt.as_hms()),
281 )
282 .field("total_energy", &self.total_energy)
283 .field("total_parking_time", &self.total_parking_time)
284 .field("total_time", &self.total_time)
285 .field("total_cost", &self.total_cost)
286 .field("total_energy_cost", &self.total_energy_cost)
287 .field("total_fixed_cost", &self.total_fixed_cost)
288 .field("total_parking_cost", &self.total_parking_cost)
289 .field("total_reservation_cost", &self.total_reservation_cost)
290 .field("total_time_cost", &self.total_time_cost)
291 .finish()
292 }
293}
294
295#[derive(Debug)]
297pub enum Warning {
298 Country(country::Warning),
299 Currency(currency::Warning),
300 DateTime(datetime::Warning),
301 Decode(json::decode::Warning),
302 Duration(duration::Warning),
303 Enum(enumeration::Warning),
304
305 CountryShouldBeAlpha2,
309
310 DimensionShouldHaveVolume {
312 dimension_name: &'static str,
313 },
314
315 FieldInvalidType {
317 expected_type: json::ValueKind,
319 },
320
321 FieldInvalidValue {
323 value: String,
325
326 message: Cow<'static, str>,
328 },
329
330 FieldRequired {
332 field_name: Cow<'static, str>,
333 },
334
335 InternalError,
339
340 Money(money::Warning),
341
342 NoPeriods,
344
345 NoValidTariff,
355
356 Number(number::Warning),
357
358 Parse(ParseError),
360
361 PeriodsOutsideStartEndDateTime {
364 cdr_range: Range<DateTime<Utc>>,
365 period_range: PeriodRange,
366 },
367
368 String(string::Warning),
369
370 Tariff(crate::tariff::Warning),
373}
374
375impl Warning {
376 fn field_invalid_value(
378 value: impl Into<String>,
379 message: impl Into<Cow<'static, str>>,
380 ) -> Self {
381 Warning::FieldInvalidValue {
382 value: value.into(),
383 message: message.into(),
384 }
385 }
386}
387
388impl fmt::Display for Warning {
389 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390 match self {
391 Self::Country(warning) => write!(f, "{warning}"),
392 Self::CountryShouldBeAlpha2 => {
393 f.write_str("The `$.country` field should be an alpha-2 country code.")
394 }
395 Self::Currency(warning) => write!(f, "{warning}"),
396 Self::DateTime(warning) => write!(f, "{warning}"),
397 Self::Decode(warning) => write!(f, "{warning}"),
398 Self::DimensionShouldHaveVolume { dimension_name } => {
399 write!(f, "Dimension `{dimension_name}` should have volume")
400 }
401 Self::Duration(warning) => write!(f, "{warning}"),
402 Self::Enum(warning) => write!(f, "{warning}"),
403 Self::FieldInvalidType { expected_type } => {
404 write!(f, "Field has invalid type. Expected type `{expected_type}`")
405 }
406 Self::FieldInvalidValue { value, message } => {
407 write!(f, "Field has invalid value `{value}`: {message}")
408 }
409 Self::FieldRequired { field_name } => {
410 write!(f, "Field is required: `{field_name}`")
411 }
412 Self::InternalError => f.write_str("Internal error"),
413 Self::Money(warning) => write!(f, "{warning}"),
414 Self::NoPeriods => f.write_str("The CDR has no charging periods"),
415 Self::NoValidTariff => {
416 f.write_str("No valid tariff has been found in the list of provided tariffs")
417 }
418 Self::Number(warning) => write!(f, "{warning}"),
419 Self::Parse(err) => {
420 write!(f, "{err}")
421 }
422 Self::PeriodsOutsideStartEndDateTime {
423 cdr_range: Range { start, end },
424 period_range,
425 } => {
426 write!(
427 f,
428 "The CDR's charging period time range is not contained within the `start_date_time` \
429 and `end_date_time`; cdr: [start: {start}, end: {end}], period: {period_range}",
430 )
431 }
432 Self::String(warning) => write!(f, "{warning}"),
433 Self::Tariff(warnings) => {
434 write!(f, "Tariff warnings: {warnings:?}")
435 }
436 }
437 }
438}
439
440impl crate::Warning for Warning {
441 fn id(&self) -> warning::Id {
442 match self {
443 Self::Country(warning) => warning.id(),
444 Self::CountryShouldBeAlpha2 => warning::Id::from_static("country_should_be_alpha_2"),
445 Self::Currency(warning) => warning.id(),
446 Self::DateTime(warning) => warning.id(),
447 Self::Decode(warning) => warning.id(),
448 Self::DimensionShouldHaveVolume { dimension_name } => {
449 warning::Id::from_string(format!("dimension_should_have_volume({dimension_name})"))
450 }
451 Self::Duration(warning) => warning.id(),
452 Self::Enum(warning) => warning.id(),
453 Self::FieldInvalidType { expected_type } => {
454 warning::Id::from_string(format!("field_invalid_type({expected_type})"))
455 }
456 Self::FieldInvalidValue { value, .. } => {
457 warning::Id::from_string(format!("field_invalid_value({value})"))
458 }
459 Self::FieldRequired { field_name } => {
460 warning::Id::from_string(format!("field_required({field_name})"))
461 }
462 Self::InternalError => warning::Id::from_static("internal_error"),
463 Self::Money(warning) => warning.id(),
464 Self::NoPeriods => warning::Id::from_static("no_periods"),
465 Self::NoValidTariff => warning::Id::from_static("no_valid_tariff"),
466 Self::Number(warning) => warning.id(),
467 Self::Parse(ParseError { object: _, kind }) => kind.id(),
468 Self::PeriodsOutsideStartEndDateTime { .. } => {
469 warning::Id::from_static("periods_outside_start_end_date_time")
470 }
471 Self::String(warning) => warning.id(),
472 Self::Tariff(warning) => warning.id(),
473 }
474 }
475}
476
477from_warning_all!(
478 country::Warning => Warning::Country,
479 currency::Warning => Warning::Currency,
480 datetime::Warning => Warning::DateTime,
481 duration::Warning => Warning::Duration,
482 enumeration::Warning => Warning::Enum,
483 json::decode::Warning => Warning::Decode,
484 money::Warning => Warning::Money,
485 number::Warning => Warning::Number,
486 string::Warning => Warning::String,
487 crate::tariff::Warning => Warning::Tariff
488);
489
490#[derive(Debug)]
492pub struct TariffReport {
493 pub origin: TariffOrigin,
495
496 pub warnings: BTreeMap<warning::Path, Vec<crate::tariff::Warning>>,
500}
501
502#[derive(Clone, Debug)]
504pub struct TariffOrigin {
505 pub index: usize,
507
508 pub id: String,
510
511 pub currency: currency::Code,
513}
514
515#[derive(Debug)]
517pub(crate) struct Period {
518 pub start_date_time: DateTime<Utc>,
520
521 pub consumed: Consumed,
523}
524
525#[derive(Debug)]
527pub struct Dimensions {
528 pub energy: Dimension<Kwh>,
530
531 pub flat: Dimension<()>,
533
534 pub duration_charging: Dimension<TimeDelta>,
536
537 pub duration_parking: Dimension<TimeDelta>,
539}
540
541impl Dimensions {
542 fn new(components: ComponentSet, consumed: &Consumed) -> Self {
543 let ComponentSet {
544 energy: energy_price,
545 flat: flat_price,
546 duration_charging: duration_charging_price,
547 duration_parking: duration_parking_price,
548 } = components;
549
550 let Consumed {
551 duration_charging,
552 duration_parking,
553 energy,
554 current_max: _,
555 current_min: _,
556 power_max: _,
557 power_min: _,
558 } = consumed;
559
560 Self {
561 energy: Dimension {
562 price: energy_price,
563 volume: *energy,
564 billed_volume: *energy,
565 },
566 flat: Dimension {
567 price: flat_price,
568 volume: Some(()),
569 billed_volume: Some(()),
570 },
571 duration_charging: Dimension {
572 price: duration_charging_price,
573 volume: *duration_charging,
574 billed_volume: *duration_charging,
575 },
576 duration_parking: Dimension {
577 price: duration_parking_price,
578 volume: *duration_parking,
579 billed_volume: *duration_parking,
580 },
581 }
582 }
583}
584
585#[derive(Debug)]
586pub struct Dimension<V> {
588 pub price: Option<Component>,
592
593 pub volume: Option<V>,
597
598 pub billed_volume: Option<V>,
606}
607
608impl<V: Cost> Dimension<V> {
609 pub fn cost(&self) -> Option<Price> {
611 let (Some(volume), Some(price_component)) = (&self.billed_volume, &self.price) else {
612 return None;
613 };
614
615 let excl_vat = volume.cost(price_component.price);
616
617 let incl_vat = match price_component.vat {
618 VatApplicable::Applicable(vat) => Some(excl_vat.apply_vat(vat)),
619 VatApplicable::Inapplicable => Some(excl_vat),
620 VatApplicable::Unknown => None,
621 };
622
623 Some(Price { excl_vat, incl_vat })
624 }
625}
626
627#[derive(Debug)]
632pub struct ComponentSet {
633 pub energy: Option<Component>,
635
636 pub flat: Option<Component>,
638
639 pub duration_charging: Option<Component>,
641
642 pub duration_parking: Option<Component>,
644}
645
646impl ComponentSet {
647 fn has_all_components(&self) -> bool {
649 let Self {
650 energy,
651 flat,
652 duration_charging,
653 duration_parking,
654 } = self;
655
656 flat.is_some()
657 && energy.is_some()
658 && duration_parking.is_some()
659 && duration_charging.is_some()
660 }
661}
662
663#[derive(Clone, Debug)]
668pub struct Component {
669 pub tariff_element_index: usize,
671
672 pub price: Money,
674
675 pub vat: VatApplicable,
678
679 pub step_size: u64,
687}
688
689impl Component {
690 fn new(component: &crate::tariff::v221::PriceComponent, tariff_element_index: usize) -> Self {
691 let crate::tariff::v221::PriceComponent {
692 price,
693 vat,
694 step_size,
695 dimension_type: _,
696 } = component;
697
698 Self {
699 tariff_element_index,
700 price: *price,
701 vat: *vat,
702 step_size: *step_size,
703 }
704 }
705}
706
707#[derive(Debug)]
721pub struct Total<TCdr, TCalc = TCdr> {
722 pub cdr: TCdr,
724
725 pub calculated: TCalc,
727}
728
729#[derive(Debug)]
731pub enum PeriodRange {
732 Many(Range<DateTime<Utc>>),
735
736 Single(DateTime<Utc>),
738}
739
740impl fmt::Display for PeriodRange {
741 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
742 match self {
743 PeriodRange::Many(Range { start, end }) => write!(f, "[start: {start}, end: {end}]"),
744 PeriodRange::Single(date_time) => write!(f, "{date_time}"),
745 }
746 }
747}
748
749#[derive(Debug)]
753pub enum TariffSource<'buf> {
754 UseCdr,
756
757 Override(Vec<crate::tariff::Versioned<'buf>>),
759}
760
761impl<'buf> TariffSource<'buf> {
762 pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
764 Self::Override(vec![tariff])
765 }
766}
767
768#[instrument(skip_all)]
769pub(super) fn cdr(
770 cdr_elem: &crate::cdr::Versioned<'_>,
771 tariff_source: TariffSource<'_>,
772 timezone: Tz,
773) -> Verdict<Report> {
774 let source_version = cdr_elem.version();
775 let cdr = parse_cdr(cdr_elem)?;
776
777 match tariff_source {
778 TariffSource::UseCdr => {
779 let (v221::cdr::WithTariffs { cdr, tariffs }, warnings) = cdr.into_parts();
780 debug!("Using tariffs from CDR");
781 let tariffs = tariffs
782 .iter()
783 .map(|elem| {
784 match source_version {
786 Version::V221 => crate::tariff::v221::Tariff::from_json(elem),
787 Version::V211 => {
788 let tariff = crate::tariff::v211::Tariff::from_json(elem);
789 tariff.map_caveat(crate::tariff::v221::Tariff::from)
792 }
793 }
794 })
795 .collect::<Result<Vec<_>, _>>()?;
796
797 let cdr = cdr.into_caveat(warnings);
798
799 Ok(price_v221_cdr_with_tariffs(
800 cdr_elem, cdr, tariffs, timezone,
801 )?)
802 }
803 TariffSource::Override(tariffs) => {
804 let cdr = cdr.map(v221::cdr::WithTariffs::discard_tariffs);
805
806 debug!("Using override tariffs");
807 let tariffs = tariffs
808 .iter()
809 .map(tariff::parse)
810 .collect::<Result<Vec<_>, _>>()?;
811
812 Ok(price_v221_cdr_with_tariffs(
813 cdr_elem, cdr, tariffs, timezone,
814 )?)
815 }
816 }
817}
818
819fn price_v221_cdr_with_tariffs(
826 cdr_elem: &crate::cdr::Versioned<'_>,
827 cdr: Caveat<v221::Cdr, Warning>,
828 tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
829 timezone: Tz,
830) -> Verdict<Report> {
831 debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
832 let (cdr, mut warnings) = cdr.into_parts();
833 let v221::Cdr {
834 start_date_time,
835 end_date_time,
836 charging_periods,
837 totals: cdr_totals,
838 } = cdr;
839
840 let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
845 .into_iter()
846 .enumerate()
847 .map(|(index, tariff)| {
848 let (tariff, warnings) = tariff.into_parts();
849 (
850 TariffReport {
851 origin: TariffOrigin {
852 index,
853 id: tariff.id.to_string(),
854 currency: tariff.currency,
855 },
856 warnings: warnings.into_path_map(),
857 },
858 tariff,
859 )
860 })
861 .unzip();
862
863 debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
864
865 let tariffs_normalized = tariff::normalize_all(&tariffs);
866 let Some((tariff_index, tariff)) =
867 tariff::find_first_active(tariffs_normalized, start_date_time)
868 else {
869 return warnings.bail(Warning::NoValidTariff, cdr_elem.as_element());
870 };
871
872 debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
873 debug!(%timezone, "Found timezone");
874 let periods = charging_periods
876 .into_iter()
877 .map(Period::try_from)
878 .collect::<Result<Vec<_>, _>>()
879 .map_err(|err| warning::ErrorSet::with_warn(Warning::Parse(err), cdr_elem.as_element()))?;
880
881 let periods = normalize_periods(periods, end_date_time, timezone);
882 let price_cdr_report = price_periods(&periods, &tariff)
883 .with_element(cdr_elem.as_element())?
884 .gather_warnings_into(&mut warnings);
885
886 let report = generate_report(
887 &cdr_totals,
888 timezone,
889 tariff_reports,
890 price_cdr_report,
891 TariffOrigin {
892 index: tariff_index,
893 id: tariff.id().to_string(),
894 currency: tariff.currency(),
895 },
896 );
897
898 Ok(report.into_caveat(warnings))
899}
900
901pub(crate) fn periods(
903 end_date_time: DateTime<Utc>,
904 timezone: Tz,
905 tariff_elem: &crate::tariff::v221::Tariff<'_>,
906 mut periods: Vec<Period>,
907) -> VerdictDeferred<PeriodsReport> {
908 periods.sort_by_key(|p| p.start_date_time);
911 let tariff = Tariff::from_v221(tariff_elem);
912 let periods = normalize_periods(periods, end_date_time, timezone);
913 price_periods(&periods, &tariff)
914}
915
916fn normalize_periods(
917 periods: Vec<Period>,
918 end_date_time: DateTime<Utc>,
919 local_timezone: Tz,
920) -> Vec<PeriodNormalized> {
921 debug!("Normalizing CDR periods");
922
923 let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
925
926 let end_dates = {
928 let mut end_dates = periods
929 .iter()
930 .skip(1)
931 .map(|p| p.start_date_time)
932 .collect::<Vec<_>>();
933
934 end_dates.push(end_date_time);
936 end_dates
937 };
938
939 let periods = periods
940 .into_iter()
941 .zip(end_dates)
942 .enumerate()
943 .map(|(index, (period, end_date_time))| {
944 trace!(index, "processing\n{period:#?}");
945 let Period {
946 start_date_time,
947 consumed,
948 } = period;
949
950 let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
951 let start_snapshot = prev_end_snapshot;
952 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
953
954 let period = PeriodNormalized {
955 consumed,
956 start_snapshot,
957 end_snapshot,
958 };
959 trace!("Adding new period based on the last added\n{period:#?}");
960 period
961 } else {
962 let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
963 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
964
965 let period = PeriodNormalized {
966 consumed,
967 start_snapshot,
968 end_snapshot,
969 };
970 trace!("Adding new period\n{period:#?}");
971 period
972 };
973
974 previous_end_snapshot.replace(period.end_snapshot.clone());
975 period
976 })
977 .collect::<Vec<_>>();
978
979 periods
980}
981
982fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
984 debug!(count = periods.len(), "Pricing CDR periods");
985
986 if tracing::enabled!(tracing::Level::TRACE) {
987 trace!("# CDR period list:");
988 for period in periods {
989 trace!("{period:#?}");
990 }
991 }
992
993 let period_totals = period_totals(periods, tariff);
994 let (billed, warnings) = period_totals.calculate_billed()?.into_parts();
995 let (billable, periods, totals) = billed;
996 let total_costs = total_costs(&periods, tariff);
997 let report = PeriodsReport {
998 billable,
999 periods,
1000 totals,
1001 total_costs,
1002 };
1003
1004 Ok(report.into_caveat_deferred(warnings))
1005}
1006
1007pub(crate) struct PeriodsReport {
1009 pub billable: Billable,
1011
1012 pub periods: Vec<PeriodReport>,
1014
1015 pub totals: Totals,
1017
1018 pub total_costs: TotalCosts,
1020}
1021
1022#[derive(Debug)]
1028pub struct PeriodReport {
1029 pub start_date_time: DateTime<Utc>,
1031
1032 pub end_date_time: DateTime<Utc>,
1034
1035 pub dimensions: Dimensions,
1037}
1038
1039impl PeriodReport {
1040 fn new(period: &PeriodNormalized, dimensions: Dimensions) -> Self {
1041 Self {
1042 start_date_time: period.start_snapshot.date_time,
1043 end_date_time: period.end_snapshot.date_time,
1044 dimensions,
1045 }
1046 }
1047
1048 pub fn cost(&self) -> Option<Price> {
1050 [
1051 self.dimensions.duration_charging.cost(),
1052 self.dimensions.duration_parking.cost(),
1053 self.dimensions.flat.cost(),
1054 self.dimensions.energy.cost(),
1055 ]
1056 .into_iter()
1057 .fold(None, |accum, next| {
1058 if accum.is_none() && next.is_none() {
1059 None
1060 } else {
1061 Some(
1062 accum
1063 .unwrap_or_default()
1064 .saturating_add(next.unwrap_or_default()),
1065 )
1066 }
1067 })
1068 }
1069}
1070
1071#[derive(Debug)]
1073struct PeriodTotals {
1074 periods: Vec<PeriodReport>,
1076
1077 step_size: StepSize,
1079
1080 totals: Totals,
1082}
1083
1084#[derive(Debug, Default)]
1086pub(crate) struct Totals {
1087 pub energy: Option<Kwh>,
1089
1090 pub duration_charging: Option<TimeDelta>,
1094
1095 pub duration_parking: Option<TimeDelta>,
1099}
1100
1101impl PeriodTotals {
1102 fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
1106 let mut warnings = warning::SetDeferred::new();
1107 let Self {
1108 mut periods,
1109 step_size,
1110 totals,
1111 } = self;
1112 let charging_time = totals
1113 .duration_charging
1114 .map(|dt| step_size.apply_time(&mut periods, dt))
1115 .transpose()?
1116 .gather_deferred_warnings_into(&mut warnings);
1117 let energy = totals
1118 .energy
1119 .map(|kwh| step_size.apply_energy(&mut periods, kwh))
1120 .transpose()?
1121 .gather_deferred_warnings_into(&mut warnings);
1122 let parking_time = totals
1123 .duration_parking
1124 .map(|dt| step_size.apply_parking_time(&mut periods, dt))
1125 .transpose()?
1126 .gather_deferred_warnings_into(&mut warnings);
1127 let billed = Billable {
1128 charging_time,
1129 energy,
1130 parking_time,
1131 };
1132 Ok((billed, periods, totals).into_caveat_deferred(warnings))
1133 }
1134}
1135
1136#[derive(Debug)]
1138pub(crate) struct Billable {
1139 charging_time: Option<TimeDelta>,
1141
1142 energy: Option<Kwh>,
1144
1145 parking_time: Option<TimeDelta>,
1147}
1148
1149fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1152 let mut has_flat_fee = false;
1153 let mut step_size = StepSize::new();
1154 let mut totals = Totals::default();
1155
1156 debug!(
1157 tariff_id = tariff.id(),
1158 period_count = periods.len(),
1159 "Accumulating dimension totals for each period"
1160 );
1161
1162 let periods = periods
1163 .iter()
1164 .enumerate()
1165 .map(|(index, period)| {
1166 let mut component_set = tariff.active_components(period);
1167 trace!(
1168 index,
1169 "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1170 );
1171
1172 if component_set.flat.is_some() {
1173 if has_flat_fee {
1174 component_set.flat = None;
1175 } else {
1176 has_flat_fee = true;
1177 }
1178 }
1179
1180 step_size.update(index, &component_set, period);
1181
1182 trace!(period_index = index, "Step size updated\n{step_size:#?}");
1183
1184 let dimensions = Dimensions::new(component_set, &period.consumed);
1185
1186 trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1187
1188 if let Some(dt) = dimensions.duration_charging.volume {
1189 let acc = totals.duration_charging.get_or_insert_default();
1190 *acc = acc.saturating_add(dt);
1191 }
1192
1193 if let Some(kwh) = dimensions.energy.volume {
1194 let acc = totals.energy.get_or_insert_default();
1195 *acc = acc.saturating_add(kwh);
1196 }
1197
1198 if let Some(dt) = dimensions.duration_parking.volume {
1199 let acc = totals.duration_parking.get_or_insert_default();
1200 *acc = acc.saturating_add(dt);
1201 }
1202
1203 trace!(period_index = index, ?totals, "Update totals");
1204
1205 PeriodReport::new(period, dimensions)
1206 })
1207 .collect::<Vec<_>>();
1208
1209 PeriodTotals {
1210 periods,
1211 step_size,
1212 totals,
1213 }
1214}
1215
1216#[derive(Debug, Default)]
1218pub(crate) struct TotalCosts {
1219 pub energy: Option<Price>,
1221
1222 pub fixed: Option<Price>,
1224
1225 pub duration_charging: Option<Price>,
1227
1228 pub duration_parking: Option<Price>,
1230}
1231
1232impl TotalCosts {
1233 pub(crate) fn total(&self) -> Option<Price> {
1237 let Self {
1238 energy,
1239 fixed,
1240 duration_charging,
1241 duration_parking,
1242 } = self;
1243 debug!(
1244 energy = %DisplayOption(*energy),
1245 fixed = %DisplayOption(*fixed),
1246 duration_charging = %DisplayOption(*duration_charging),
1247 duration_parking = %DisplayOption(*duration_parking),
1248 "Calculating total costs."
1249 );
1250 [energy, fixed, duration_charging, duration_parking]
1251 .into_iter()
1252 .fold(None, |accum: Option<Price>, next| match (accum, next) {
1253 (None, None) => None,
1254 _ => Some(
1255 accum
1256 .unwrap_or_default()
1257 .saturating_add(next.unwrap_or_default()),
1258 ),
1259 })
1260 }
1261}
1262
1263fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1265 let mut total_costs = TotalCosts::default();
1266
1267 debug!(
1268 tariff_id = tariff.id(),
1269 period_count = periods.len(),
1270 "Accumulating dimension costs for each period"
1271 );
1272 for (index, period) in periods.iter().enumerate() {
1273 let dimensions = &period.dimensions;
1274
1275 trace!(period_index = index, "Processing period");
1276
1277 let energy_cost = dimensions.energy.cost();
1278 let fixed_cost = dimensions.flat.cost();
1279 let duration_charging_cost = dimensions.duration_charging.cost();
1280 let duration_parking_cost = dimensions.duration_parking.cost();
1281
1282 trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1283 trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1284 trace!(?total_costs.duration_parking, ?duration_parking_cost, "Parking cost");
1285 trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1286
1287 total_costs.energy = match (total_costs.energy, energy_cost) {
1288 (None, None) => None,
1289 (total, period) => Some(
1290 total
1291 .unwrap_or_default()
1292 .saturating_add(period.unwrap_or_default()),
1293 ),
1294 };
1295
1296 total_costs.duration_charging =
1297 match (total_costs.duration_charging, duration_charging_cost) {
1298 (None, None) => None,
1299 (total, period) => Some(
1300 total
1301 .unwrap_or_default()
1302 .saturating_add(period.unwrap_or_default()),
1303 ),
1304 };
1305
1306 total_costs.duration_parking = match (total_costs.duration_parking, duration_parking_cost) {
1307 (None, None) => None,
1308 (total, period) => Some(
1309 total
1310 .unwrap_or_default()
1311 .saturating_add(period.unwrap_or_default()),
1312 ),
1313 };
1314
1315 total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1316 (None, None) => None,
1317 (total, period) => Some(
1318 total
1319 .unwrap_or_default()
1320 .saturating_add(period.unwrap_or_default()),
1321 ),
1322 };
1323
1324 trace!(period_index = index, ?total_costs, "Update totals");
1325 }
1326
1327 total_costs
1328}
1329
1330fn generate_report(
1331 cdr_totals: &v221::cdr::Totals,
1332 timezone: Tz,
1333 tariff_reports: Vec<TariffReport>,
1334 price_periods_report: PeriodsReport,
1335 tariff_used: TariffOrigin,
1336) -> Report {
1337 let PeriodsReport {
1338 billable,
1339 periods,
1340 totals,
1341 total_costs,
1342 } = price_periods_report;
1343 trace!("Update billed totals {billable:#?}");
1344
1345 let total_cost = total_costs.total();
1346
1347 debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1348
1349 let total_time = {
1350 debug!(
1351 period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1352 period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1353 "Calculating `total_time`"
1354 );
1355
1356 periods
1357 .first()
1358 .zip(periods.last())
1359 .map(|(first, last)| {
1360 last.end_date_time
1361 .signed_duration_since(first.start_date_time)
1362 })
1363 .unwrap_or_default()
1364 };
1365 debug!(total_time = %Hms(total_time));
1366
1367 let report = Report {
1368 periods,
1369 tariff_used,
1370 timezone: timezone.to_string(),
1371 billed_parking_time: billable.parking_time,
1372 billed_energy: billable.energy.round_to_ocpi_scale(),
1373 billed_charging_time: billable.charging_time,
1374 tariff_reports,
1375 total_charging_time: totals.duration_charging,
1376 total_cost: Total {
1377 cdr: cdr_totals.cost.round_to_ocpi_scale(),
1378 calculated: total_cost.round_to_ocpi_scale(),
1379 },
1380 total_time_cost: Total {
1381 cdr: cdr_totals.time_cost.round_to_ocpi_scale(),
1382 calculated: total_costs.duration_charging.round_to_ocpi_scale(),
1383 },
1384 total_time: Total {
1385 cdr: cdr_totals.time,
1386 calculated: total_time,
1387 },
1388 total_parking_cost: Total {
1389 cdr: cdr_totals.parking_cost.round_to_ocpi_scale(),
1390 calculated: total_costs.duration_parking.round_to_ocpi_scale(),
1391 },
1392 total_parking_time: Total {
1393 cdr: cdr_totals.parking_time,
1394 calculated: totals.duration_parking,
1395 },
1396 total_energy_cost: Total {
1397 cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
1398 calculated: total_costs.energy.round_to_ocpi_scale(),
1399 },
1400 total_energy: Total {
1401 cdr: cdr_totals.energy.round_to_ocpi_scale(),
1402 calculated: totals.energy.round_to_ocpi_scale(),
1403 },
1404 total_fixed_cost: Total {
1405 cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
1406 calculated: total_costs.fixed.round_to_ocpi_scale(),
1407 },
1408 total_reservation_cost: Total {
1409 cdr: cdr_totals.reservation_cost.round_to_ocpi_scale(),
1410 calculated: None,
1411 },
1412 };
1413
1414 trace!("{report:#?}");
1415
1416 report
1417}
1418
1419#[derive(Debug)]
1420struct StepSize {
1421 charging_time: Option<(usize, Component)>,
1422 parking_time: Option<(usize, Component)>,
1423 energy: Option<(usize, Component)>,
1424}
1425
1426fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1428 Decimal::from(delta.num_milliseconds())
1429 .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1430 .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1431}
1432
1433fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1435 let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1436 let Ok(millis) = i64::try_from(millis) else {
1437 return Err(warning::ErrorSetDeferred::with_warn(
1438 duration::Warning::Overflow.into(),
1439 ));
1440 };
1441 let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1442 return Err(warning::ErrorSetDeferred::with_warn(
1443 duration::Warning::Overflow.into(),
1444 ));
1445 };
1446 Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1447}
1448
1449impl StepSize {
1450 fn new() -> Self {
1451 Self {
1452 charging_time: None,
1453 parking_time: None,
1454 energy: None,
1455 }
1456 }
1457
1458 fn update(&mut self, index: usize, components: &ComponentSet, period: &PeriodNormalized) {
1459 if period.consumed.energy.is_some() {
1460 if let Some(energy) = components.energy.clone() {
1461 self.energy = Some((index, energy));
1462 }
1463 }
1464
1465 if period.consumed.duration_charging.is_some() {
1466 if let Some(time) = components.duration_charging.clone() {
1467 self.charging_time = Some((index, time));
1468 }
1469 }
1470
1471 if period.consumed.duration_parking.is_some() {
1472 if let Some(parking) = components.duration_parking.clone() {
1473 self.parking_time = Some((index, parking));
1474 }
1475 }
1476 }
1477
1478 fn duration_step_size(
1479 total_volume: TimeDelta,
1480 period_billed_volume: &mut TimeDelta,
1481 step_size: u64,
1482 ) -> VerdictDeferred<TimeDelta> {
1483 if step_size == 0 {
1484 return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1485 }
1486
1487 let total_seconds = delta_as_seconds_dec(total_volume);
1488 let step_size = Decimal::from(step_size);
1489
1490 let Some(x) = total_seconds.checked_div(step_size) else {
1491 return Err(warning::ErrorSetDeferred::with_warn(
1492 duration::Warning::Overflow.into(),
1493 ));
1494 };
1495 let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1496
1497 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1498 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1499
1500 Ok(total_billed_volume)
1501 }
1502
1503 fn apply_time(
1504 &self,
1505 periods: &mut [PeriodReport],
1506 total: TimeDelta,
1507 ) -> VerdictDeferred<TimeDelta> {
1508 let (Some((time_index, price)), None) = (&self.charging_time, &self.parking_time) else {
1509 return Ok(total.into_caveat_deferred(warning::SetDeferred::new()));
1510 };
1511
1512 let Some(period) = periods.get_mut(*time_index) else {
1513 error!(time_index, "Invalid period index");
1514 return Err(warning::ErrorSetDeferred::with_warn(Warning::InternalError));
1515 };
1516 let Some(volume) = period.dimensions.duration_charging.billed_volume.as_mut() else {
1517 return Err(warning::ErrorSetDeferred::with_warn(
1518 Warning::DimensionShouldHaveVolume {
1519 dimension_name: "time",
1520 },
1521 ));
1522 };
1523
1524 Self::duration_step_size(total, volume, price.step_size)
1525 }
1526
1527 fn apply_parking_time(
1528 &self,
1529 periods: &mut [PeriodReport],
1530 total: TimeDelta,
1531 ) -> VerdictDeferred<TimeDelta> {
1532 let warnings = warning::SetDeferred::new();
1533 let Some((parking_index, price)) = &self.parking_time else {
1534 return Ok(total.into_caveat_deferred(warnings));
1535 };
1536
1537 let Some(period) = periods.get_mut(*parking_index) else {
1538 error!(parking_index, "Invalid period index");
1539 return warnings.bail(Warning::InternalError);
1540 };
1541 let Some(volume) = period.dimensions.duration_parking.billed_volume.as_mut() else {
1542 return warnings.bail(Warning::DimensionShouldHaveVolume {
1543 dimension_name: "parking_time",
1544 });
1545 };
1546
1547 Self::duration_step_size(total, volume, price.step_size)
1548 }
1549
1550 fn apply_energy(
1551 &self,
1552 periods: &mut [PeriodReport],
1553 total_volume: Kwh,
1554 ) -> VerdictDeferred<Kwh> {
1555 let warnings = warning::SetDeferred::new();
1556 let Some((energy_index, price)) = &self.energy else {
1557 return Ok(total_volume.into_caveat_deferred(warnings));
1558 };
1559
1560 if price.step_size == 0 {
1561 return Ok(total_volume.into_caveat_deferred(warnings));
1562 }
1563
1564 let Some(period) = periods.get_mut(*energy_index) else {
1565 error!(energy_index, "Invalid period index");
1566 return warnings.bail(Warning::InternalError);
1567 };
1568 let step_size = Decimal::from(price.step_size);
1569
1570 let Some(period_billed_volume) = period.dimensions.energy.billed_volume.as_mut() else {
1571 return warnings.bail(Warning::DimensionShouldHaveVolume {
1572 dimension_name: "energy",
1573 });
1574 };
1575
1576 let Some(watt_hours) = total_volume.watt_hours().checked_div(step_size) else {
1577 return warnings.bail(duration::Warning::Overflow.into());
1578 };
1579
1580 let total_billed_volume = Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_size));
1581 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1582 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1583
1584 Ok(total_billed_volume.into_caveat_deferred(warnings))
1585 }
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}