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
18mod tariff;
19mod v211;
20mod v221;
21
22use std::{borrow::Cow, collections::BTreeMap, fmt, ops::Range};
23
24use chrono::{DateTime, Datelike, TimeDelta, Utc};
25use chrono_tz::Tz;
26use rust_decimal::Decimal;
27use tracing::{debug, error, instrument, trace};
28
29use crate::{
30 country, currency, datetime,
31 duration::{self, AsHms, Hms},
32 enumeration, from_warning_all,
33 json::{self, FromJson as _},
34 money,
35 number::{self, RoundDecimal},
36 string,
37 warning::{
38 self, GatherDeferredWarnings as _, GatherWarnings as _, IntoCaveat,
39 IntoCaveatDeferred as _, VerdictExt as _, WithElement as _,
40 },
41 Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, ParseError, Price, SaturatingAdd as _,
42 SaturatingSub as _, VatApplicable, Version, Versioned as _,
43};
44
45use tariff::Tariff;
46
47type Verdict<T> = crate::Verdict<T, Warning>;
48type VerdictDeferred<T> = warning::VerdictDeferred<T, Warning>;
49
50#[derive(Debug)]
55struct PeriodNormalized {
56 consumed: Consumed,
58
59 start_snapshot: TotalsSnapshot,
61
62 end_snapshot: TotalsSnapshot,
64}
65
66#[derive(Clone)]
68#[cfg_attr(test, derive(Default))]
69pub(crate) struct Consumed {
70 pub current_max: Option<Ampere>,
72
73 pub current_min: Option<Ampere>,
75
76 pub duration_charging: Option<TimeDelta>,
78
79 pub duration_parking: Option<TimeDelta>,
81
82 pub energy: Option<Kwh>,
84
85 pub power_max: Option<Kw>,
87
88 pub power_min: Option<Kw>,
90}
91
92impl fmt::Debug for Consumed {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 f.debug_struct("Consumed")
95 .field("current_max", &self.current_max)
96 .field("current_min", &self.current_min)
97 .field(
98 "duration_charging",
99 &self.duration_charging.map(|dt| dt.as_hms()),
100 )
101 .field(
102 "duration_parking",
103 &self.duration_parking.map(|dt| dt.as_hms()),
104 )
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
175 next
176 }
177
178 fn local_time(&self) -> chrono::NaiveTime {
180 self.date_time.with_timezone(&self.local_timezone).time()
181 }
182
183 fn local_date(&self) -> chrono::NaiveDate {
185 self.date_time
186 .with_timezone(&self.local_timezone)
187 .date_naive()
188 }
189
190 fn local_weekday(&self) -> chrono::Weekday {
192 self.date_time.with_timezone(&self.local_timezone).weekday()
193 }
194}
195
196pub struct Report {
199 pub periods: Vec<PeriodReport>,
201
202 pub tariff_used: TariffOrigin,
204
205 pub tariff_reports: Vec<TariffReport>,
209
210 pub timezone: String,
212
213 pub billed_charging_time: Option<TimeDelta>,
216
217 pub billed_energy: Option<Kwh>,
219
220 pub billed_parking_time: Option<TimeDelta>,
222
223 pub total_charging_time: Option<TimeDelta>,
229
230 pub total_energy: Total<Kwh, Option<Kwh>>,
232
233 pub total_parking_time: Total<Option<TimeDelta>>,
235
236 pub total_time: Total<TimeDelta>,
238
239 pub total_cost: Total<Price, Option<Price>>,
242
243 pub total_energy_cost: Total<Option<Price>>,
245
246 pub total_fixed_cost: Total<Option<Price>>,
248
249 pub total_parking_cost: Total<Option<Price>>,
251
252 pub total_reservation_cost: Total<Option<Price>>,
254
255 pub total_time_cost: Total<Option<Price>>,
257}
258
259impl fmt::Debug for Report {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 f.debug_struct("Report")
262 .field("periods", &self.periods)
263 .field("tariff_used", &self.tariff_used)
264 .field("tariff_reports", &self.tariff_reports)
265 .field("timezone", &self.timezone)
266 .field(
267 "billed_charging_time",
268 &self.billed_charging_time.map(|dt| dt.as_hms()),
269 )
270 .field("billed_energy", &self.billed_energy)
271 .field(
272 "billed_parking_time",
273 &self.billed_parking_time.map(|dt| dt.as_hms()),
274 )
275 .field(
276 "total_charging_time",
277 &self.total_charging_time.map(|dt| dt.as_hms()),
278 )
279 .field("total_energy", &self.total_energy)
280 .field("total_parking_time", &self.total_parking_time)
281 .field("total_time", &self.total_time)
282 .field("total_cost", &self.total_cost)
283 .field("total_energy_cost", &self.total_energy_cost)
284 .field("total_fixed_cost", &self.total_fixed_cost)
285 .field("total_parking_cost", &self.total_parking_cost)
286 .field("total_reservation_cost", &self.total_reservation_cost)
287 .field("total_time_cost", &self.total_time_cost)
288 .finish()
289 }
290}
291
292#[derive(Debug)]
294pub enum Warning {
295 Country(country::Warning),
296 Currency(currency::Warning),
297 DateTime(datetime::Warning),
298 Decode(json::decode::Warning),
299 Duration(duration::Warning),
300 Enum(enumeration::Warning),
301
302 CountryShouldBeAlpha2,
306
307 DimensionShouldHaveVolume {
309 dimension_name: &'static str,
310 },
311
312 FieldInvalidType {
314 expected_type: json::ValueKind,
316 },
317
318 FieldInvalidValue {
320 value: String,
322
323 message: Cow<'static, str>,
325 },
326
327 FieldRequired {
329 field_name: Cow<'static, str>,
330 },
331
332 InternalError,
336
337 Money(money::Warning),
338
339 NoPeriods,
341
342 NoValidTariff,
352
353 Number(number::Warning),
354
355 Parse(ParseError),
357
358 PeriodsOutsideStartEndDateTime {
361 cdr_range: Range<DateTime<Utc>>,
362 period_range: PeriodRange,
363 },
364
365 String(string::Warning),
366
367 Tariff(crate::tariff::Warning),
370}
371
372impl Warning {
373 fn field_invalid_value(
375 value: impl Into<String>,
376 message: impl Into<Cow<'static, str>>,
377 ) -> Self {
378 Warning::FieldInvalidValue {
379 value: value.into(),
380 message: message.into(),
381 }
382 }
383}
384
385impl fmt::Display for Warning {
386 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387 match self {
388 Self::Country(warning) => write!(f, "{warning}"),
389 Self::CountryShouldBeAlpha2 => {
390 f.write_str("The `$.country` field should be an alpha-2 country code.")
391 }
392 Self::Currency(warning) => write!(f, "{warning}"),
393 Self::DateTime(warning) => write!(f, "{warning}"),
394 Self::Decode(warning) => write!(f, "{warning}"),
395 Self::DimensionShouldHaveVolume { dimension_name } => {
396 write!(f, "Dimension `{dimension_name}` should have volume")
397 }
398 Self::Duration(warning) => write!(f, "{warning}"),
399 Self::Enum(warning) => write!(f, "{warning}"),
400 Self::FieldInvalidType { expected_type } => {
401 write!(f, "Field has invalid type. Expected type `{expected_type}`")
402 }
403 Self::FieldInvalidValue { value, message } => {
404 write!(f, "Field has invalid value `{value}`: {message}")
405 }
406 Self::FieldRequired { field_name } => {
407 write!(f, "Field is required: `{field_name}`")
408 }
409 Self::InternalError => f.write_str("Internal error"),
410 Self::Money(warning) => write!(f, "{warning}"),
411 Self::NoPeriods => f.write_str("The CDR has no charging periods"),
412 Self::NoValidTariff => {
413 f.write_str("No valid tariff has been found in the list of provided tariffs")
414 }
415 Self::Number(warning) => write!(f, "{warning}"),
416 Self::Parse(err) => {
417 write!(f, "{err}")
418 }
419 Self::PeriodsOutsideStartEndDateTime {
420 cdr_range: Range { start, end },
421 period_range,
422 } => {
423 write!(
424 f,
425 "The CDR's charging period time range is not contained within the `start_date_time` \
426 and `end_date_time`; cdr: [start: {start}, end: {end}], period: {period_range}",
427 )
428 }
429 Self::String(warning) => write!(f, "{warning}"),
430 Self::Tariff(warnings) => {
431 write!(f, "Tariff warnings: {warnings:?}")
432 }
433 }
434 }
435}
436
437impl crate::Warning for Warning {
438 fn id(&self) -> warning::Id {
439 match self {
440 Self::Country(warning) => warning.id(),
441 Self::CountryShouldBeAlpha2 => warning::Id::from_static("country_should_be_alpha_2"),
442 Self::Currency(warning) => warning.id(),
443 Self::DateTime(warning) => warning.id(),
444 Self::Decode(warning) => warning.id(),
445 Self::DimensionShouldHaveVolume { dimension_name } => {
446 warning::Id::from_string(format!("dimension_should_have_volume({dimension_name})"))
447 }
448 Self::Duration(warning) => warning.id(),
449 Self::Enum(warning) => warning.id(),
450 Self::FieldInvalidType { expected_type } => {
451 warning::Id::from_string(format!("field_invalid_type({expected_type})"))
452 }
453 Self::FieldInvalidValue { value, .. } => {
454 warning::Id::from_string(format!("field_invalid_value({value})"))
455 }
456 Self::FieldRequired { field_name } => {
457 warning::Id::from_string(format!("field_required({field_name})"))
458 }
459 Self::InternalError => warning::Id::from_static("internal_error"),
460 Self::Money(warning) => warning.id(),
461 Self::NoPeriods => warning::Id::from_static("no_periods"),
462 Self::NoValidTariff => warning::Id::from_static("no_valid_tariff"),
463 Self::Number(warning) => warning.id(),
464 Self::Parse(ParseError { object: _, kind }) => kind.id(),
465 Self::PeriodsOutsideStartEndDateTime { .. } => {
466 warning::Id::from_static("periods_outside_start_end_date_time")
467 }
468 Self::String(warning) => warning.id(),
469 Self::Tariff(warning) => warning.id(),
470 }
471 }
472}
473
474from_warning_all!(
475 country::Warning => Warning::Country,
476 currency::Warning => Warning::Currency,
477 datetime::Warning => Warning::DateTime,
478 duration::Warning => Warning::Duration,
479 enumeration::Warning => Warning::Enum,
480 json::decode::Warning => Warning::Decode,
481 money::Warning => Warning::Money,
482 number::Warning => Warning::Number,
483 string::Warning => Warning::String,
484 crate::tariff::Warning => Warning::Tariff
485);
486
487#[derive(Debug)]
489pub struct TariffReport {
490 pub origin: TariffOrigin,
492
493 pub warnings: BTreeMap<warning::Path, Vec<crate::tariff::Warning>>,
497}
498
499#[derive(Clone, Debug)]
501pub struct TariffOrigin {
502 pub index: usize,
504
505 pub id: String,
507
508 pub currency: currency::Code,
510}
511
512#[derive(Debug)]
514pub(crate) struct Period {
515 pub start_date_time: DateTime<Utc>,
517
518 pub consumed: Consumed,
520}
521
522#[derive(Debug)]
524pub struct Dimensions {
525 pub energy: Dimension<Kwh>,
527
528 pub flat: Dimension<()>,
530
531 pub duration_charging: Dimension<TimeDelta>,
533
534 pub duration_parking: Dimension<TimeDelta>,
536}
537
538impl Dimensions {
539 fn new(components: ComponentSet, consumed: &Consumed) -> Self {
540 let ComponentSet {
541 energy: energy_price,
542 flat: flat_price,
543 duration_charging: duration_charging_price,
544 duration_parking: duration_parking_price,
545 } = components;
546
547 let Consumed {
548 duration_charging,
549 duration_parking,
550 energy,
551 current_max: _,
552 current_min: _,
553 power_max: _,
554 power_min: _,
555 } = consumed;
556
557 Self {
558 energy: Dimension {
559 price: energy_price,
560 volume: *energy,
561 billed_volume: *energy,
562 },
563 flat: Dimension {
564 price: flat_price,
565 volume: Some(()),
566 billed_volume: Some(()),
567 },
568 duration_charging: Dimension {
569 price: duration_charging_price,
570 volume: *duration_charging,
571 billed_volume: *duration_charging,
572 },
573 duration_parking: Dimension {
574 price: duration_parking_price,
575 volume: *duration_parking,
576 billed_volume: *duration_parking,
577 },
578 }
579 }
580}
581
582#[derive(Debug)]
583pub struct Dimension<V> {
585 pub price: Option<Component>,
589
590 pub volume: Option<V>,
594
595 pub billed_volume: Option<V>,
603}
604
605impl<V: Cost> Dimension<V> {
606 pub fn cost(&self) -> Option<Price> {
608 let (Some(volume), Some(price_component)) = (&self.billed_volume, &self.price) else {
609 return None;
610 };
611
612 let excl_vat = volume.cost(price_component.price);
613
614 let incl_vat = match price_component.vat {
615 VatApplicable::Applicable(vat) => Some(excl_vat.apply_vat(vat)),
616 VatApplicable::Inapplicable => Some(excl_vat),
617 VatApplicable::Unknown => None,
618 };
619
620 Some(Price { excl_vat, incl_vat })
621 }
622}
623
624#[derive(Debug)]
629pub struct ComponentSet {
630 pub energy: Option<Component>,
632
633 pub flat: Option<Component>,
635
636 pub duration_charging: Option<Component>,
638
639 pub duration_parking: Option<Component>,
641}
642
643impl ComponentSet {
644 fn has_all_components(&self) -> bool {
646 let Self {
647 energy,
648 flat,
649 duration_charging,
650 duration_parking,
651 } = self;
652
653 flat.is_some()
654 && energy.is_some()
655 && duration_parking.is_some()
656 && duration_charging.is_some()
657 }
658}
659
660#[derive(Clone, Debug)]
665pub struct Component {
666 pub tariff_element_index: usize,
668
669 pub price: Money,
671
672 pub vat: VatApplicable,
675
676 pub step_size: u64,
684}
685
686impl Component {
687 fn new(component: &crate::tariff::v221::PriceComponent, tariff_element_index: usize) -> Self {
688 let crate::tariff::v221::PriceComponent {
689 price,
690 vat,
691 step_size,
692 dimension_type: _,
693 } = component;
694
695 Self {
696 tariff_element_index,
697 price: *price,
698 vat: *vat,
699 step_size: *step_size,
700 }
701 }
702}
703
704#[derive(Debug)]
718pub struct Total<TCdr, TCalc = TCdr> {
719 pub cdr: TCdr,
721
722 pub calculated: TCalc,
724}
725
726#[derive(Debug)]
728pub enum PeriodRange {
729 Many(Range<DateTime<Utc>>),
732
733 Single(DateTime<Utc>),
735}
736
737impl fmt::Display for PeriodRange {
738 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
739 match self {
740 PeriodRange::Many(Range { start, end }) => write!(f, "[start: {start}, end: {end}]"),
741 PeriodRange::Single(date_time) => write!(f, "{date_time}"),
742 }
743 }
744}
745
746#[derive(Debug)]
750pub enum TariffSource<'buf> {
751 UseCdr,
753
754 Override(Vec<crate::tariff::Versioned<'buf>>),
756}
757
758impl<'buf> TariffSource<'buf> {
759 pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
761 Self::Override(vec![tariff])
762 }
763}
764
765#[instrument(skip_all)]
766pub(super) fn cdr(
767 cdr_elem: &crate::cdr::Versioned<'_>,
768 tariff_source: TariffSource<'_>,
769 timezone: Tz,
770) -> Verdict<Report> {
771 let cdr = parse_cdr(cdr_elem)?;
772
773 match tariff_source {
774 TariffSource::UseCdr => {
775 let (v221::cdr::WithTariffs { cdr, tariffs }, warnings) = cdr.into_parts();
776 debug!("Using tariffs from CDR");
777 let tariffs = tariffs
778 .iter()
779 .map(|elem| {
780 let tariff = crate::tariff::v211::Tariff::from_json(elem);
781 tariff.map_caveat(crate::tariff::v221::Tariff::from)
782 })
783 .collect::<Result<Vec<_>, _>>()?;
784
785 let cdr = cdr.into_caveat(warnings);
786
787 Ok(price_v221_cdr_with_tariffs(
788 cdr_elem, cdr, tariffs, timezone,
789 )?)
790 }
791 TariffSource::Override(tariffs) => {
792 let cdr = cdr.map(v221::cdr::WithTariffs::discard_tariffs);
793
794 debug!("Using override tariffs");
795 let tariffs = tariffs
796 .iter()
797 .map(tariff::parse)
798 .collect::<Result<Vec<_>, _>>()?;
799
800 Ok(price_v221_cdr_with_tariffs(
801 cdr_elem, cdr, tariffs, timezone,
802 )?)
803 }
804 }
805}
806
807fn price_v221_cdr_with_tariffs(
814 cdr_elem: &crate::cdr::Versioned<'_>,
815 cdr: Caveat<v221::Cdr, Warning>,
816 tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
817 timezone: Tz,
818) -> Verdict<Report> {
819 debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
820 let (cdr, mut warnings) = cdr.into_parts();
821 let v221::Cdr {
822 start_date_time,
823 end_date_time,
824 charging_periods,
825 totals: cdr_totals,
826 } = cdr;
827
828 let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
833 .into_iter()
834 .enumerate()
835 .map(|(index, tariff)| {
836 let (tariff, warnings) = tariff.into_parts();
837 (
838 TariffReport {
839 origin: TariffOrigin {
840 index,
841 id: tariff.id.to_string(),
842 currency: tariff.currency,
843 },
844 warnings: warnings.into_path_map(),
845 },
846 tariff,
847 )
848 })
849 .unzip();
850
851 debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
852
853 let tariffs_normalized = tariff::normalize_all(&tariffs);
854 let Some((tariff_index, tariff)) =
855 tariff::find_first_active(tariffs_normalized, start_date_time)
856 else {
857 return warnings.bail(Warning::NoValidTariff, cdr_elem.as_element());
858 };
859
860 debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
861 debug!(%timezone, "Found timezone");
862 let periods = charging_periods
864 .into_iter()
865 .map(Period::try_from)
866 .collect::<Result<Vec<_>, _>>()
867 .map_err(|err| warning::ErrorSet::with_warn(Warning::Parse(err), cdr_elem.as_element()))?;
868
869 let periods = normalize_periods(periods, end_date_time, timezone);
870 let price_cdr_report = price_periods(&periods, &tariff)
871 .with_element(cdr_elem.as_element())?
872 .gather_warnings_into(&mut warnings);
873
874 let report = generate_report(
875 &cdr_totals,
876 timezone,
877 tariff_reports,
878 price_cdr_report,
879 TariffOrigin {
880 index: tariff_index,
881 id: tariff.id().to_string(),
882 currency: tariff.currency(),
883 },
884 );
885
886 Ok(report.into_caveat(warnings))
887}
888
889pub(crate) fn periods(
891 end_date_time: DateTime<Utc>,
892 timezone: Tz,
893 tariff_elem: &crate::tariff::v221::Tariff<'_>,
894 mut periods: Vec<Period>,
895) -> VerdictDeferred<PeriodsReport> {
896 periods.sort_by_key(|p| p.start_date_time);
899 let tariff = Tariff::from_v221(tariff_elem);
900 let periods = normalize_periods(periods, end_date_time, timezone);
901 price_periods(&periods, &tariff)
902}
903
904fn normalize_periods(
905 periods: Vec<Period>,
906 end_date_time: DateTime<Utc>,
907 local_timezone: Tz,
908) -> Vec<PeriodNormalized> {
909 debug!("Normalizing CDR periods");
910
911 let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
913
914 let end_dates = {
916 let mut end_dates = periods
917 .iter()
918 .skip(1)
919 .map(|p| p.start_date_time)
920 .collect::<Vec<_>>();
921
922 end_dates.push(end_date_time);
924 end_dates
925 };
926
927 let periods = periods
928 .into_iter()
929 .zip(end_dates)
930 .enumerate()
931 .map(|(index, (period, end_date_time))| {
932 trace!(index, "processing\n{period:#?}");
933 let Period {
934 start_date_time,
935 consumed,
936 } = period;
937
938 let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
939 let start_snapshot = prev_end_snapshot;
940 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
941
942 let period = PeriodNormalized {
943 consumed,
944 start_snapshot,
945 end_snapshot,
946 };
947 trace!("Adding new period based on the last added\n{period:#?}");
948 period
949 } else {
950 let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
951 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
952
953 let period = PeriodNormalized {
954 consumed,
955 start_snapshot,
956 end_snapshot,
957 };
958 trace!("Adding new period\n{period:#?}");
959 period
960 };
961
962 previous_end_snapshot.replace(period.end_snapshot.clone());
963 period
964 })
965 .collect::<Vec<_>>();
966
967 periods
968}
969
970fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
972 debug!(count = periods.len(), "Pricing CDR periods");
973
974 if tracing::enabled!(tracing::Level::TRACE) {
975 trace!("# CDR period list:");
976 for period in periods {
977 trace!("{period:#?}");
978 }
979 }
980
981 let period_totals = period_totals(periods, tariff);
982 let (billed, warnings) = period_totals.calculate_billed()?.into_parts();
983 let (billable, periods, totals) = billed;
984 let total_costs = total_costs(&periods, tariff);
985 let report = PeriodsReport {
986 billable,
987 periods,
988 totals,
989 total_costs,
990 };
991
992 Ok(report.into_caveat_deferred(warnings))
993}
994
995pub(crate) struct PeriodsReport {
997 pub billable: Billable,
999
1000 pub periods: Vec<PeriodReport>,
1002
1003 pub totals: Totals,
1005
1006 pub total_costs: TotalCosts,
1008}
1009
1010#[derive(Debug)]
1016pub struct PeriodReport {
1017 pub start_date_time: DateTime<Utc>,
1019
1020 pub end_date_time: DateTime<Utc>,
1022
1023 pub dimensions: Dimensions,
1025}
1026
1027impl PeriodReport {
1028 fn new(period: &PeriodNormalized, dimensions: Dimensions) -> Self {
1029 Self {
1030 start_date_time: period.start_snapshot.date_time,
1031 end_date_time: period.end_snapshot.date_time,
1032 dimensions,
1033 }
1034 }
1035
1036 pub fn cost(&self) -> Option<Price> {
1038 [
1039 self.dimensions.duration_charging.cost(),
1040 self.dimensions.duration_parking.cost(),
1041 self.dimensions.flat.cost(),
1042 self.dimensions.energy.cost(),
1043 ]
1044 .into_iter()
1045 .fold(None, |accum, next| {
1046 if accum.is_none() && next.is_none() {
1047 None
1048 } else {
1049 Some(
1050 accum
1051 .unwrap_or_default()
1052 .saturating_add(next.unwrap_or_default()),
1053 )
1054 }
1055 })
1056 }
1057}
1058
1059#[derive(Debug)]
1061struct PeriodTotals {
1062 periods: Vec<PeriodReport>,
1064
1065 step_size: StepSize,
1067
1068 totals: Totals,
1070}
1071
1072#[derive(Debug, Default)]
1074pub(crate) struct Totals {
1075 pub energy: Option<Kwh>,
1077
1078 pub duration_charging: Option<TimeDelta>,
1082
1083 pub duration_parking: Option<TimeDelta>,
1087}
1088
1089impl PeriodTotals {
1090 fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
1094 let mut warnings = warning::SetDeferred::new();
1095 let Self {
1096 mut periods,
1097 step_size,
1098 totals,
1099 } = self;
1100 let charging_time = totals
1101 .duration_charging
1102 .map(|dt| step_size.apply_time(&mut periods, dt))
1103 .transpose()?
1104 .gather_deferred_warnings_into(&mut warnings);
1105 let energy = totals
1106 .energy
1107 .map(|kwh| step_size.apply_energy(&mut periods, kwh))
1108 .transpose()?
1109 .gather_deferred_warnings_into(&mut warnings);
1110 let parking_time = totals
1111 .duration_parking
1112 .map(|dt| step_size.apply_parking_time(&mut periods, dt))
1113 .transpose()?
1114 .gather_deferred_warnings_into(&mut warnings);
1115 let billed = Billable {
1116 charging_time,
1117 energy,
1118 parking_time,
1119 };
1120 Ok((billed, periods, totals).into_caveat_deferred(warnings))
1121 }
1122}
1123
1124#[derive(Debug)]
1126pub(crate) struct Billable {
1127 charging_time: Option<TimeDelta>,
1129
1130 energy: Option<Kwh>,
1132
1133 parking_time: Option<TimeDelta>,
1135}
1136
1137fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1140 let mut has_flat_fee = false;
1141 let mut step_size = StepSize::new();
1142 let mut totals = Totals::default();
1143
1144 debug!(
1145 tariff_id = tariff.id(),
1146 period_count = periods.len(),
1147 "Accumulating dimension totals for each period"
1148 );
1149
1150 let periods = periods
1151 .iter()
1152 .enumerate()
1153 .map(|(index, period)| {
1154 let mut component_set = tariff.active_components(period);
1155 trace!(
1156 index,
1157 "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1158 );
1159
1160 if component_set.flat.is_some() {
1161 if has_flat_fee {
1162 component_set.flat = None;
1163 } else {
1164 has_flat_fee = true;
1165 }
1166 }
1167
1168 step_size.update(index, &component_set, period);
1169
1170 trace!(period_index = index, "Step size updated\n{step_size:#?}");
1171
1172 let dimensions = Dimensions::new(component_set, &period.consumed);
1173
1174 trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1175
1176 if let Some(dt) = dimensions.duration_charging.volume {
1177 let acc = totals.duration_charging.get_or_insert_default();
1178 *acc = acc.saturating_add(dt);
1179 }
1180
1181 if let Some(kwh) = dimensions.energy.volume {
1182 let acc = totals.energy.get_or_insert_default();
1183 *acc = acc.saturating_add(kwh);
1184 }
1185
1186 if let Some(dt) = dimensions.duration_parking.volume {
1187 let acc = totals.duration_parking.get_or_insert_default();
1188 *acc = acc.saturating_add(dt);
1189 }
1190
1191 trace!(period_index = index, ?totals, "Update totals");
1192
1193 PeriodReport::new(period, dimensions)
1194 })
1195 .collect::<Vec<_>>();
1196
1197 PeriodTotals {
1198 periods,
1199 step_size,
1200 totals,
1201 }
1202}
1203
1204#[derive(Debug, Default)]
1206pub(crate) struct TotalCosts {
1207 pub energy: Option<Price>,
1209
1210 pub fixed: Option<Price>,
1212
1213 pub duration_charging: Option<Price>,
1215
1216 pub duration_parking: Option<Price>,
1218}
1219
1220impl TotalCosts {
1221 pub(crate) fn total(&self) -> Option<Price> {
1225 let Self {
1226 energy,
1227 fixed,
1228 duration_charging,
1229 duration_parking,
1230 } = self;
1231 debug!(
1232 energy = %DisplayOption(*energy),
1233 fixed = %DisplayOption(*fixed),
1234 duration_charging = %DisplayOption(*duration_charging),
1235 duration_parking = %DisplayOption(*duration_parking),
1236 "Calculating total costs."
1237 );
1238 [energy, fixed, duration_charging, duration_parking]
1239 .into_iter()
1240 .fold(None, |accum: Option<Price>, next| match (accum, next) {
1241 (None, None) => None,
1242 _ => Some(
1243 accum
1244 .unwrap_or_default()
1245 .saturating_add(next.unwrap_or_default()),
1246 ),
1247 })
1248 }
1249}
1250
1251fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1253 let mut total_costs = TotalCosts::default();
1254
1255 debug!(
1256 tariff_id = tariff.id(),
1257 period_count = periods.len(),
1258 "Accumulating dimension costs for each period"
1259 );
1260 for (index, period) in periods.iter().enumerate() {
1261 let dimensions = &period.dimensions;
1262
1263 trace!(period_index = index, "Processing period");
1264
1265 let energy_cost = dimensions.energy.cost();
1266 let fixed_cost = dimensions.flat.cost();
1267 let duration_charging_cost = dimensions.duration_charging.cost();
1268 let duration_parking_cost = dimensions.duration_parking.cost();
1269
1270 trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1271 trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1272 trace!(?total_costs.duration_parking, ?duration_parking_cost, "Parking cost");
1273 trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1274
1275 total_costs.energy = match (total_costs.energy, energy_cost) {
1276 (None, None) => None,
1277 (total, period) => Some(
1278 total
1279 .unwrap_or_default()
1280 .saturating_add(period.unwrap_or_default()),
1281 ),
1282 };
1283
1284 total_costs.duration_charging =
1285 match (total_costs.duration_charging, duration_charging_cost) {
1286 (None, None) => None,
1287 (total, period) => Some(
1288 total
1289 .unwrap_or_default()
1290 .saturating_add(period.unwrap_or_default()),
1291 ),
1292 };
1293
1294 total_costs.duration_parking = match (total_costs.duration_parking, duration_parking_cost) {
1295 (None, None) => None,
1296 (total, period) => Some(
1297 total
1298 .unwrap_or_default()
1299 .saturating_add(period.unwrap_or_default()),
1300 ),
1301 };
1302
1303 total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1304 (None, None) => None,
1305 (total, period) => Some(
1306 total
1307 .unwrap_or_default()
1308 .saturating_add(period.unwrap_or_default()),
1309 ),
1310 };
1311
1312 trace!(period_index = index, ?total_costs, "Update totals");
1313 }
1314
1315 total_costs
1316}
1317
1318fn generate_report(
1319 cdr_totals: &v221::cdr::Totals,
1320 timezone: Tz,
1321 tariff_reports: Vec<TariffReport>,
1322 price_periods_report: PeriodsReport,
1323 tariff_used: TariffOrigin,
1324) -> Report {
1325 let PeriodsReport {
1326 billable,
1327 periods,
1328 totals,
1329 total_costs,
1330 } = price_periods_report;
1331 trace!("Update billed totals {billable:#?}");
1332
1333 let total_cost = total_costs.total();
1334
1335 debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1336
1337 let total_time = {
1338 debug!(
1339 period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1340 period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1341 "Calculating `total_time`"
1342 );
1343
1344 periods
1345 .first()
1346 .zip(periods.last())
1347 .map(|(first, last)| {
1348 last.end_date_time
1349 .signed_duration_since(first.start_date_time)
1350 })
1351 .unwrap_or_default()
1352 };
1353 debug!(total_time = %Hms(total_time));
1354
1355 let report = Report {
1356 periods,
1357 tariff_used,
1358 timezone: timezone.to_string(),
1359 billed_parking_time: billable.parking_time,
1360 billed_energy: billable.energy.round_to_ocpi_scale(),
1361 billed_charging_time: billable.charging_time,
1362 tariff_reports,
1363 total_charging_time: totals.duration_charging,
1364 total_cost: Total {
1365 cdr: cdr_totals.cost.round_to_ocpi_scale(),
1366 calculated: total_cost.round_to_ocpi_scale(),
1367 },
1368 total_time_cost: Total {
1369 cdr: cdr_totals.time_cost.round_to_ocpi_scale(),
1370 calculated: total_costs.duration_charging.round_to_ocpi_scale(),
1371 },
1372 total_time: Total {
1373 cdr: cdr_totals.time,
1374 calculated: total_time,
1375 },
1376 total_parking_cost: Total {
1377 cdr: cdr_totals.parking_cost.round_to_ocpi_scale(),
1378 calculated: total_costs.duration_parking.round_to_ocpi_scale(),
1379 },
1380 total_parking_time: Total {
1381 cdr: cdr_totals.parking_time,
1382 calculated: totals.duration_parking,
1383 },
1384 total_energy_cost: Total {
1385 cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
1386 calculated: total_costs.energy.round_to_ocpi_scale(),
1387 },
1388 total_energy: Total {
1389 cdr: cdr_totals.energy.round_to_ocpi_scale(),
1390 calculated: totals.energy.round_to_ocpi_scale(),
1391 },
1392 total_fixed_cost: Total {
1393 cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
1394 calculated: total_costs.fixed.round_to_ocpi_scale(),
1395 },
1396 total_reservation_cost: Total {
1397 cdr: cdr_totals.reservation_cost.round_to_ocpi_scale(),
1398 calculated: None,
1399 },
1400 };
1401
1402 trace!("{report:#?}");
1403
1404 report
1405}
1406
1407#[derive(Debug)]
1408struct StepSize {
1409 charging_time: Option<(usize, Component)>,
1410 parking_time: Option<(usize, Component)>,
1411 energy: Option<(usize, Component)>,
1412}
1413
1414fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1416 Decimal::from(delta.num_milliseconds())
1417 .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1418 .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1419}
1420
1421fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1423 let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1424 let Ok(millis) = i64::try_from(millis) else {
1425 return Err(warning::ErrorSetDeferred::with_warn(
1426 duration::Warning::Overflow.into(),
1427 ));
1428 };
1429 let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1430 return Err(warning::ErrorSetDeferred::with_warn(
1431 duration::Warning::Overflow.into(),
1432 ));
1433 };
1434 Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1435}
1436
1437impl StepSize {
1438 fn new() -> Self {
1439 Self {
1440 charging_time: None,
1441 parking_time: None,
1442 energy: None,
1443 }
1444 }
1445
1446 fn update(&mut self, index: usize, components: &ComponentSet, period: &PeriodNormalized) {
1447 if period.consumed.energy.is_some() {
1448 if let Some(energy) = components.energy.clone() {
1449 self.energy = Some((index, energy));
1450 }
1451 }
1452
1453 if period.consumed.duration_charging.is_some() {
1454 if let Some(time) = components.duration_charging.clone() {
1455 self.charging_time = Some((index, time));
1456 }
1457 }
1458
1459 if period.consumed.duration_parking.is_some() {
1460 if let Some(parking) = components.duration_parking.clone() {
1461 self.parking_time = Some((index, parking));
1462 }
1463 }
1464 }
1465
1466 fn duration_step_size(
1467 total_volume: TimeDelta,
1468 period_billed_volume: &mut TimeDelta,
1469 step_size: u64,
1470 ) -> VerdictDeferred<TimeDelta> {
1471 if step_size == 0 {
1472 return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1473 }
1474
1475 let total_seconds = delta_as_seconds_dec(total_volume);
1476 let step_size = Decimal::from(step_size);
1477
1478 let Some(x) = total_seconds.checked_div(step_size) else {
1479 return Err(warning::ErrorSetDeferred::with_warn(
1480 duration::Warning::Overflow.into(),
1481 ));
1482 };
1483 let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1484
1485 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1486 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1487
1488 Ok(total_billed_volume)
1489 }
1490
1491 fn apply_time(
1492 &self,
1493 periods: &mut [PeriodReport],
1494 total: TimeDelta,
1495 ) -> VerdictDeferred<TimeDelta> {
1496 let (Some((time_index, price)), None) = (&self.charging_time, &self.parking_time) else {
1497 return Ok(total.into_caveat_deferred(warning::SetDeferred::new()));
1498 };
1499
1500 let Some(period) = periods.get_mut(*time_index) else {
1501 error!(time_index, "Invalid period index");
1502 return Err(warning::ErrorSetDeferred::with_warn(Warning::InternalError));
1503 };
1504 let Some(volume) = period.dimensions.duration_charging.billed_volume.as_mut() else {
1505 return Err(warning::ErrorSetDeferred::with_warn(
1506 Warning::DimensionShouldHaveVolume {
1507 dimension_name: "time",
1508 },
1509 ));
1510 };
1511
1512 Self::duration_step_size(total, volume, price.step_size)
1513 }
1514
1515 fn apply_parking_time(
1516 &self,
1517 periods: &mut [PeriodReport],
1518 total: TimeDelta,
1519 ) -> VerdictDeferred<TimeDelta> {
1520 let warnings = warning::SetDeferred::new();
1521 let Some((parking_index, price)) = &self.parking_time else {
1522 return Ok(total.into_caveat_deferred(warnings));
1523 };
1524
1525 let Some(period) = periods.get_mut(*parking_index) else {
1526 error!(parking_index, "Invalid period index");
1527 return warnings.bail(Warning::InternalError);
1528 };
1529 let Some(volume) = period.dimensions.duration_parking.billed_volume.as_mut() else {
1530 return warnings.bail(Warning::DimensionShouldHaveVolume {
1531 dimension_name: "parking_time",
1532 });
1533 };
1534
1535 Self::duration_step_size(total, volume, price.step_size)
1536 }
1537
1538 fn apply_energy(
1539 &self,
1540 periods: &mut [PeriodReport],
1541 total_volume: Kwh,
1542 ) -> VerdictDeferred<Kwh> {
1543 let warnings = warning::SetDeferred::new();
1544 let Some((energy_index, price)) = &self.energy else {
1545 return Ok(total_volume.into_caveat_deferred(warnings));
1546 };
1547
1548 if price.step_size == 0 {
1549 return Ok(total_volume.into_caveat_deferred(warnings));
1550 }
1551
1552 let Some(period) = periods.get_mut(*energy_index) else {
1553 error!(energy_index, "Invalid period index");
1554 return warnings.bail(Warning::InternalError);
1555 };
1556 let step_size = Decimal::from(price.step_size);
1557
1558 let Some(period_billed_volume) = period.dimensions.energy.billed_volume.as_mut() else {
1559 return warnings.bail(Warning::DimensionShouldHaveVolume {
1560 dimension_name: "energy",
1561 });
1562 };
1563
1564 let Some(watt_hours) = total_volume.watt_hours().checked_div(step_size) else {
1565 return warnings.bail(duration::Warning::Overflow.into());
1566 };
1567
1568 let total_billed_volume = Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_size));
1569 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1570 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1571
1572 Ok(total_billed_volume.into_caveat_deferred(warnings))
1573 }
1574}
1575
1576fn parse_cdr<'buf>(cdr: &crate::cdr::Versioned<'buf>) -> Verdict<v221::cdr::WithTariffs<'buf>> {
1577 match cdr.version() {
1578 Version::V211 => {
1579 let cdr = v211::cdr::WithTariffs::from_json(cdr.as_element())?;
1580 Ok(cdr.map(v221::cdr::WithTariffs::from))
1581 }
1582 Version::V221 => v221::cdr::WithTariffs::from_json(cdr.as_element()),
1583 }
1584}