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 source_version = cdr_elem.version();
772 let cdr = parse_cdr(cdr_elem)?;
773
774 match tariff_source {
775 TariffSource::UseCdr => {
776 let (v221::cdr::WithTariffs { cdr, tariffs }, warnings) = cdr.into_parts();
777 debug!("Using tariffs from CDR");
778 let tariffs = tariffs
779 .iter()
780 .map(|elem| {
781 match source_version {
783 Version::V221 => crate::tariff::v221::Tariff::from_json(elem),
784 Version::V211 => {
785 let tariff = crate::tariff::v211::Tariff::from_json(elem);
786 tariff.map_caveat(crate::tariff::v221::Tariff::from)
789 }
790 }
791 })
792 .collect::<Result<Vec<_>, _>>()?;
793
794 let cdr = cdr.into_caveat(warnings);
795
796 Ok(price_v221_cdr_with_tariffs(
797 cdr_elem, cdr, tariffs, timezone,
798 )?)
799 }
800 TariffSource::Override(tariffs) => {
801 let cdr = cdr.map(v221::cdr::WithTariffs::discard_tariffs);
802
803 debug!("Using override tariffs");
804 let tariffs = tariffs
805 .iter()
806 .map(tariff::parse)
807 .collect::<Result<Vec<_>, _>>()?;
808
809 Ok(price_v221_cdr_with_tariffs(
810 cdr_elem, cdr, tariffs, timezone,
811 )?)
812 }
813 }
814}
815
816fn price_v221_cdr_with_tariffs(
823 cdr_elem: &crate::cdr::Versioned<'_>,
824 cdr: Caveat<v221::Cdr, Warning>,
825 tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
826 timezone: Tz,
827) -> Verdict<Report> {
828 debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
829 let (cdr, mut warnings) = cdr.into_parts();
830 let v221::Cdr {
831 start_date_time,
832 end_date_time,
833 charging_periods,
834 totals: cdr_totals,
835 } = cdr;
836
837 let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
842 .into_iter()
843 .enumerate()
844 .map(|(index, tariff)| {
845 let (tariff, warnings) = tariff.into_parts();
846 (
847 TariffReport {
848 origin: TariffOrigin {
849 index,
850 id: tariff.id.to_string(),
851 currency: tariff.currency,
852 },
853 warnings: warnings.into_path_map(),
854 },
855 tariff,
856 )
857 })
858 .unzip();
859
860 debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
861
862 let tariffs_normalized = tariff::normalize_all(&tariffs);
863 let Some((tariff_index, tariff)) =
864 tariff::find_first_active(tariffs_normalized, start_date_time)
865 else {
866 return warnings.bail(Warning::NoValidTariff, cdr_elem.as_element());
867 };
868
869 debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
870 debug!(%timezone, "Found timezone");
871 let periods = charging_periods
873 .into_iter()
874 .map(Period::try_from)
875 .collect::<Result<Vec<_>, _>>()
876 .map_err(|err| warning::ErrorSet::with_warn(Warning::Parse(err), cdr_elem.as_element()))?;
877
878 let periods = normalize_periods(periods, end_date_time, timezone);
879 let price_cdr_report = price_periods(&periods, &tariff)
880 .with_element(cdr_elem.as_element())?
881 .gather_warnings_into(&mut warnings);
882
883 let report = generate_report(
884 &cdr_totals,
885 timezone,
886 tariff_reports,
887 price_cdr_report,
888 TariffOrigin {
889 index: tariff_index,
890 id: tariff.id().to_string(),
891 currency: tariff.currency(),
892 },
893 );
894
895 Ok(report.into_caveat(warnings))
896}
897
898pub(crate) fn periods(
900 end_date_time: DateTime<Utc>,
901 timezone: Tz,
902 tariff_elem: &crate::tariff::v221::Tariff<'_>,
903 mut periods: Vec<Period>,
904) -> VerdictDeferred<PeriodsReport> {
905 periods.sort_by_key(|p| p.start_date_time);
908 let tariff = Tariff::from_v221(tariff_elem);
909 let periods = normalize_periods(periods, end_date_time, timezone);
910 price_periods(&periods, &tariff)
911}
912
913fn normalize_periods(
914 periods: Vec<Period>,
915 end_date_time: DateTime<Utc>,
916 local_timezone: Tz,
917) -> Vec<PeriodNormalized> {
918 debug!("Normalizing CDR periods");
919
920 let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
922
923 let end_dates = {
925 let mut end_dates = periods
926 .iter()
927 .skip(1)
928 .map(|p| p.start_date_time)
929 .collect::<Vec<_>>();
930
931 end_dates.push(end_date_time);
933 end_dates
934 };
935
936 let periods = periods
937 .into_iter()
938 .zip(end_dates)
939 .enumerate()
940 .map(|(index, (period, end_date_time))| {
941 trace!(index, "processing\n{period:#?}");
942 let Period {
943 start_date_time,
944 consumed,
945 } = period;
946
947 let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
948 let start_snapshot = prev_end_snapshot;
949 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
950
951 let period = PeriodNormalized {
952 consumed,
953 start_snapshot,
954 end_snapshot,
955 };
956 trace!("Adding new period based on the last added\n{period:#?}");
957 period
958 } else {
959 let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
960 let end_snapshot = start_snapshot.next(&consumed, end_date_time);
961
962 let period = PeriodNormalized {
963 consumed,
964 start_snapshot,
965 end_snapshot,
966 };
967 trace!("Adding new period\n{period:#?}");
968 period
969 };
970
971 previous_end_snapshot.replace(period.end_snapshot.clone());
972 period
973 })
974 .collect::<Vec<_>>();
975
976 periods
977}
978
979fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
981 debug!(count = periods.len(), "Pricing CDR periods");
982
983 if tracing::enabled!(tracing::Level::TRACE) {
984 trace!("# CDR period list:");
985 for period in periods {
986 trace!("{period:#?}");
987 }
988 }
989
990 let period_totals = period_totals(periods, tariff);
991 let (billed, warnings) = period_totals.calculate_billed()?.into_parts();
992 let (billable, periods, totals) = billed;
993 let total_costs = total_costs(&periods, tariff);
994 let report = PeriodsReport {
995 billable,
996 periods,
997 totals,
998 total_costs,
999 };
1000
1001 Ok(report.into_caveat_deferred(warnings))
1002}
1003
1004pub(crate) struct PeriodsReport {
1006 pub billable: Billable,
1008
1009 pub periods: Vec<PeriodReport>,
1011
1012 pub totals: Totals,
1014
1015 pub total_costs: TotalCosts,
1017}
1018
1019#[derive(Debug)]
1025pub struct PeriodReport {
1026 pub start_date_time: DateTime<Utc>,
1028
1029 pub end_date_time: DateTime<Utc>,
1031
1032 pub dimensions: Dimensions,
1034}
1035
1036impl PeriodReport {
1037 fn new(period: &PeriodNormalized, dimensions: Dimensions) -> Self {
1038 Self {
1039 start_date_time: period.start_snapshot.date_time,
1040 end_date_time: period.end_snapshot.date_time,
1041 dimensions,
1042 }
1043 }
1044
1045 pub fn cost(&self) -> Option<Price> {
1047 [
1048 self.dimensions.duration_charging.cost(),
1049 self.dimensions.duration_parking.cost(),
1050 self.dimensions.flat.cost(),
1051 self.dimensions.energy.cost(),
1052 ]
1053 .into_iter()
1054 .fold(None, |accum, next| {
1055 if accum.is_none() && next.is_none() {
1056 None
1057 } else {
1058 Some(
1059 accum
1060 .unwrap_or_default()
1061 .saturating_add(next.unwrap_or_default()),
1062 )
1063 }
1064 })
1065 }
1066}
1067
1068#[derive(Debug)]
1070struct PeriodTotals {
1071 periods: Vec<PeriodReport>,
1073
1074 step_size: StepSize,
1076
1077 totals: Totals,
1079}
1080
1081#[derive(Debug, Default)]
1083pub(crate) struct Totals {
1084 pub energy: Option<Kwh>,
1086
1087 pub duration_charging: Option<TimeDelta>,
1091
1092 pub duration_parking: Option<TimeDelta>,
1096}
1097
1098impl PeriodTotals {
1099 fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
1103 let mut warnings = warning::SetDeferred::new();
1104 let Self {
1105 mut periods,
1106 step_size,
1107 totals,
1108 } = self;
1109 let charging_time = totals
1110 .duration_charging
1111 .map(|dt| step_size.apply_time(&mut periods, dt))
1112 .transpose()?
1113 .gather_deferred_warnings_into(&mut warnings);
1114 let energy = totals
1115 .energy
1116 .map(|kwh| step_size.apply_energy(&mut periods, kwh))
1117 .transpose()?
1118 .gather_deferred_warnings_into(&mut warnings);
1119 let parking_time = totals
1120 .duration_parking
1121 .map(|dt| step_size.apply_parking_time(&mut periods, dt))
1122 .transpose()?
1123 .gather_deferred_warnings_into(&mut warnings);
1124 let billed = Billable {
1125 charging_time,
1126 energy,
1127 parking_time,
1128 };
1129 Ok((billed, periods, totals).into_caveat_deferred(warnings))
1130 }
1131}
1132
1133#[derive(Debug)]
1135pub(crate) struct Billable {
1136 charging_time: Option<TimeDelta>,
1138
1139 energy: Option<Kwh>,
1141
1142 parking_time: Option<TimeDelta>,
1144}
1145
1146fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1149 let mut has_flat_fee = false;
1150 let mut step_size = StepSize::new();
1151 let mut totals = Totals::default();
1152
1153 debug!(
1154 tariff_id = tariff.id(),
1155 period_count = periods.len(),
1156 "Accumulating dimension totals for each period"
1157 );
1158
1159 let periods = periods
1160 .iter()
1161 .enumerate()
1162 .map(|(index, period)| {
1163 let mut component_set = tariff.active_components(period);
1164 trace!(
1165 index,
1166 "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1167 );
1168
1169 if component_set.flat.is_some() {
1170 if has_flat_fee {
1171 component_set.flat = None;
1172 } else {
1173 has_flat_fee = true;
1174 }
1175 }
1176
1177 step_size.update(index, &component_set, period);
1178
1179 trace!(period_index = index, "Step size updated\n{step_size:#?}");
1180
1181 let dimensions = Dimensions::new(component_set, &period.consumed);
1182
1183 trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1184
1185 if let Some(dt) = dimensions.duration_charging.volume {
1186 let acc = totals.duration_charging.get_or_insert_default();
1187 *acc = acc.saturating_add(dt);
1188 }
1189
1190 if let Some(kwh) = dimensions.energy.volume {
1191 let acc = totals.energy.get_or_insert_default();
1192 *acc = acc.saturating_add(kwh);
1193 }
1194
1195 if let Some(dt) = dimensions.duration_parking.volume {
1196 let acc = totals.duration_parking.get_or_insert_default();
1197 *acc = acc.saturating_add(dt);
1198 }
1199
1200 trace!(period_index = index, ?totals, "Update totals");
1201
1202 PeriodReport::new(period, dimensions)
1203 })
1204 .collect::<Vec<_>>();
1205
1206 PeriodTotals {
1207 periods,
1208 step_size,
1209 totals,
1210 }
1211}
1212
1213#[derive(Debug, Default)]
1215pub(crate) struct TotalCosts {
1216 pub energy: Option<Price>,
1218
1219 pub fixed: Option<Price>,
1221
1222 pub duration_charging: Option<Price>,
1224
1225 pub duration_parking: Option<Price>,
1227}
1228
1229impl TotalCosts {
1230 pub(crate) fn total(&self) -> Option<Price> {
1234 let Self {
1235 energy,
1236 fixed,
1237 duration_charging,
1238 duration_parking,
1239 } = self;
1240 debug!(
1241 energy = %DisplayOption(*energy),
1242 fixed = %DisplayOption(*fixed),
1243 duration_charging = %DisplayOption(*duration_charging),
1244 duration_parking = %DisplayOption(*duration_parking),
1245 "Calculating total costs."
1246 );
1247 [energy, fixed, duration_charging, duration_parking]
1248 .into_iter()
1249 .fold(None, |accum: Option<Price>, next| match (accum, next) {
1250 (None, None) => None,
1251 _ => Some(
1252 accum
1253 .unwrap_or_default()
1254 .saturating_add(next.unwrap_or_default()),
1255 ),
1256 })
1257 }
1258}
1259
1260fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1262 let mut total_costs = TotalCosts::default();
1263
1264 debug!(
1265 tariff_id = tariff.id(),
1266 period_count = periods.len(),
1267 "Accumulating dimension costs for each period"
1268 );
1269 for (index, period) in periods.iter().enumerate() {
1270 let dimensions = &period.dimensions;
1271
1272 trace!(period_index = index, "Processing period");
1273
1274 let energy_cost = dimensions.energy.cost();
1275 let fixed_cost = dimensions.flat.cost();
1276 let duration_charging_cost = dimensions.duration_charging.cost();
1277 let duration_parking_cost = dimensions.duration_parking.cost();
1278
1279 trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1280 trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1281 trace!(?total_costs.duration_parking, ?duration_parking_cost, "Parking cost");
1282 trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1283
1284 total_costs.energy = match (total_costs.energy, energy_cost) {
1285 (None, None) => None,
1286 (total, period) => Some(
1287 total
1288 .unwrap_or_default()
1289 .saturating_add(period.unwrap_or_default()),
1290 ),
1291 };
1292
1293 total_costs.duration_charging =
1294 match (total_costs.duration_charging, duration_charging_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.duration_parking = match (total_costs.duration_parking, duration_parking_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 total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1313 (None, None) => None,
1314 (total, period) => Some(
1315 total
1316 .unwrap_or_default()
1317 .saturating_add(period.unwrap_or_default()),
1318 ),
1319 };
1320
1321 trace!(period_index = index, ?total_costs, "Update totals");
1322 }
1323
1324 total_costs
1325}
1326
1327fn generate_report(
1328 cdr_totals: &v221::cdr::Totals,
1329 timezone: Tz,
1330 tariff_reports: Vec<TariffReport>,
1331 price_periods_report: PeriodsReport,
1332 tariff_used: TariffOrigin,
1333) -> Report {
1334 let PeriodsReport {
1335 billable,
1336 periods,
1337 totals,
1338 total_costs,
1339 } = price_periods_report;
1340 trace!("Update billed totals {billable:#?}");
1341
1342 let total_cost = total_costs.total();
1343
1344 debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1345
1346 let total_time = {
1347 debug!(
1348 period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1349 period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1350 "Calculating `total_time`"
1351 );
1352
1353 periods
1354 .first()
1355 .zip(periods.last())
1356 .map(|(first, last)| {
1357 last.end_date_time
1358 .signed_duration_since(first.start_date_time)
1359 })
1360 .unwrap_or_default()
1361 };
1362 debug!(total_time = %Hms(total_time));
1363
1364 let report = Report {
1365 periods,
1366 tariff_used,
1367 timezone: timezone.to_string(),
1368 billed_parking_time: billable.parking_time,
1369 billed_energy: billable.energy.round_to_ocpi_scale(),
1370 billed_charging_time: billable.charging_time,
1371 tariff_reports,
1372 total_charging_time: totals.duration_charging,
1373 total_cost: Total {
1374 cdr: cdr_totals.cost.round_to_ocpi_scale(),
1375 calculated: total_cost.round_to_ocpi_scale(),
1376 },
1377 total_time_cost: Total {
1378 cdr: cdr_totals.time_cost.round_to_ocpi_scale(),
1379 calculated: total_costs.duration_charging.round_to_ocpi_scale(),
1380 },
1381 total_time: Total {
1382 cdr: cdr_totals.time,
1383 calculated: total_time,
1384 },
1385 total_parking_cost: Total {
1386 cdr: cdr_totals.parking_cost.round_to_ocpi_scale(),
1387 calculated: total_costs.duration_parking.round_to_ocpi_scale(),
1388 },
1389 total_parking_time: Total {
1390 cdr: cdr_totals.parking_time,
1391 calculated: totals.duration_parking,
1392 },
1393 total_energy_cost: Total {
1394 cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
1395 calculated: total_costs.energy.round_to_ocpi_scale(),
1396 },
1397 total_energy: Total {
1398 cdr: cdr_totals.energy.round_to_ocpi_scale(),
1399 calculated: totals.energy.round_to_ocpi_scale(),
1400 },
1401 total_fixed_cost: Total {
1402 cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
1403 calculated: total_costs.fixed.round_to_ocpi_scale(),
1404 },
1405 total_reservation_cost: Total {
1406 cdr: cdr_totals.reservation_cost.round_to_ocpi_scale(),
1407 calculated: None,
1408 },
1409 };
1410
1411 trace!("{report:#?}");
1412
1413 report
1414}
1415
1416#[derive(Debug)]
1417struct StepSize {
1418 charging_time: Option<(usize, Component)>,
1419 parking_time: Option<(usize, Component)>,
1420 energy: Option<(usize, Component)>,
1421}
1422
1423fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1425 Decimal::from(delta.num_milliseconds())
1426 .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1427 .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1428}
1429
1430fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1432 let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1433 let Ok(millis) = i64::try_from(millis) else {
1434 return Err(warning::ErrorSetDeferred::with_warn(
1435 duration::Warning::Overflow.into(),
1436 ));
1437 };
1438 let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1439 return Err(warning::ErrorSetDeferred::with_warn(
1440 duration::Warning::Overflow.into(),
1441 ));
1442 };
1443 Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1444}
1445
1446impl StepSize {
1447 fn new() -> Self {
1448 Self {
1449 charging_time: None,
1450 parking_time: None,
1451 energy: None,
1452 }
1453 }
1454
1455 fn update(&mut self, index: usize, components: &ComponentSet, period: &PeriodNormalized) {
1456 if period.consumed.energy.is_some() {
1457 if let Some(energy) = components.energy.clone() {
1458 self.energy = Some((index, energy));
1459 }
1460 }
1461
1462 if period.consumed.duration_charging.is_some() {
1463 if let Some(time) = components.duration_charging.clone() {
1464 self.charging_time = Some((index, time));
1465 }
1466 }
1467
1468 if period.consumed.duration_parking.is_some() {
1469 if let Some(parking) = components.duration_parking.clone() {
1470 self.parking_time = Some((index, parking));
1471 }
1472 }
1473 }
1474
1475 fn duration_step_size(
1476 total_volume: TimeDelta,
1477 period_billed_volume: &mut TimeDelta,
1478 step_size: u64,
1479 ) -> VerdictDeferred<TimeDelta> {
1480 if step_size == 0 {
1481 return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1482 }
1483
1484 let total_seconds = delta_as_seconds_dec(total_volume);
1485 let step_size = Decimal::from(step_size);
1486
1487 let Some(x) = total_seconds.checked_div(step_size) else {
1488 return Err(warning::ErrorSetDeferred::with_warn(
1489 duration::Warning::Overflow.into(),
1490 ));
1491 };
1492 let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1493
1494 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1495 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1496
1497 Ok(total_billed_volume)
1498 }
1499
1500 fn apply_time(
1501 &self,
1502 periods: &mut [PeriodReport],
1503 total: TimeDelta,
1504 ) -> VerdictDeferred<TimeDelta> {
1505 let (Some((time_index, price)), None) = (&self.charging_time, &self.parking_time) else {
1506 return Ok(total.into_caveat_deferred(warning::SetDeferred::new()));
1507 };
1508
1509 let Some(period) = periods.get_mut(*time_index) else {
1510 error!(time_index, "Invalid period index");
1511 return Err(warning::ErrorSetDeferred::with_warn(Warning::InternalError));
1512 };
1513 let Some(volume) = period.dimensions.duration_charging.billed_volume.as_mut() else {
1514 return Err(warning::ErrorSetDeferred::with_warn(
1515 Warning::DimensionShouldHaveVolume {
1516 dimension_name: "time",
1517 },
1518 ));
1519 };
1520
1521 Self::duration_step_size(total, volume, price.step_size)
1522 }
1523
1524 fn apply_parking_time(
1525 &self,
1526 periods: &mut [PeriodReport],
1527 total: TimeDelta,
1528 ) -> VerdictDeferred<TimeDelta> {
1529 let warnings = warning::SetDeferred::new();
1530 let Some((parking_index, price)) = &self.parking_time else {
1531 return Ok(total.into_caveat_deferred(warnings));
1532 };
1533
1534 let Some(period) = periods.get_mut(*parking_index) else {
1535 error!(parking_index, "Invalid period index");
1536 return warnings.bail(Warning::InternalError);
1537 };
1538 let Some(volume) = period.dimensions.duration_parking.billed_volume.as_mut() else {
1539 return warnings.bail(Warning::DimensionShouldHaveVolume {
1540 dimension_name: "parking_time",
1541 });
1542 };
1543
1544 Self::duration_step_size(total, volume, price.step_size)
1545 }
1546
1547 fn apply_energy(
1548 &self,
1549 periods: &mut [PeriodReport],
1550 total_volume: Kwh,
1551 ) -> VerdictDeferred<Kwh> {
1552 let warnings = warning::SetDeferred::new();
1553 let Some((energy_index, price)) = &self.energy else {
1554 return Ok(total_volume.into_caveat_deferred(warnings));
1555 };
1556
1557 if price.step_size == 0 {
1558 return Ok(total_volume.into_caveat_deferred(warnings));
1559 }
1560
1561 let Some(period) = periods.get_mut(*energy_index) else {
1562 error!(energy_index, "Invalid period index");
1563 return warnings.bail(Warning::InternalError);
1564 };
1565 let step_size = Decimal::from(price.step_size);
1566
1567 let Some(period_billed_volume) = period.dimensions.energy.billed_volume.as_mut() else {
1568 return warnings.bail(Warning::DimensionShouldHaveVolume {
1569 dimension_name: "energy",
1570 });
1571 };
1572
1573 let Some(watt_hours) = total_volume.watt_hours().checked_div(step_size) else {
1574 return warnings.bail(duration::Warning::Overflow.into());
1575 };
1576
1577 let total_billed_volume = Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_size));
1578 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1579 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1580
1581 Ok(total_billed_volume.into_caveat_deferred(warnings))
1582 }
1583}
1584
1585fn parse_cdr<'buf>(cdr: &crate::cdr::Versioned<'buf>) -> Verdict<v221::cdr::WithTariffs<'buf>> {
1586 match cdr.version() {
1587 Version::V211 => {
1588 let cdr = v211::cdr::WithTariffs::from_json(cdr.as_element())?;
1589 Ok(cdr.map(v221::cdr::WithTariffs::from))
1590 }
1591 Version::V221 => v221::cdr::WithTariffs::from_json(cdr.as_element()),
1592 }
1593}