1mod de;
4mod tariff;
5mod v211;
6mod v221;
7
8use std::{borrow::Cow, fmt, ops::Range};
9
10use chrono::{DateTime, Datelike, TimeDelta, Utc};
11use chrono_tz::Tz;
12use rust_decimal::Decimal;
13use tracing::{debug, instrument, trace};
14
15use crate::{
16 duration::{self, Hms},
17 warning, Ampere, Cost, DisplayOption, Kw, Kwh, Money, ParseError, Price, SaturatingAdd as _,
18 SaturatingSub as _, UnexpectedFields, VatApplicable, Version, Versioned as _,
19};
20
21use tariff::Tariff;
22
23#[derive(Debug)]
28struct PeriodNormalized {
29 consumed: Consumed,
31
32 start_snapshot: TotalsSnapshot,
34
35 end_snapshot: TotalsSnapshot,
37}
38
39#[derive(Clone, Debug)]
41#[cfg_attr(test, derive(Default))]
42pub(crate) struct Consumed {
43 current_max: Option<Ampere>,
45
46 current_min: Option<Ampere>,
48
49 energy: Option<Kwh>,
51
52 power_max: Option<Kw>,
54
55 power_min: Option<Kw>,
57
58 duration_charging: Option<TimeDelta>,
60
61 duration_parking: Option<TimeDelta>,
63}
64
65#[derive(Clone, Debug)]
67struct TotalsSnapshot {
68 date_time: DateTime<Utc>,
70
71 energy: Kwh,
73
74 local_timezone: Tz,
76
77 duration_charging: TimeDelta,
79
80 duration_total: TimeDelta,
82}
83
84impl TotalsSnapshot {
85 fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
87 Self {
88 date_time,
89 energy: Kwh::zero(),
90 local_timezone,
91 duration_charging: TimeDelta::zero(),
92 duration_total: TimeDelta::zero(),
93 }
94 }
95
96 fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
98 let duration = date_time.signed_duration_since(self.date_time);
99
100 let mut next = Self {
101 date_time,
102 energy: self.energy,
103 local_timezone: self.local_timezone,
104 duration_charging: self.duration_charging,
105 duration_total: self
106 .duration_total
107 .checked_add(&duration)
108 .unwrap_or(TimeDelta::MAX),
109 };
110
111 if let Some(duration) = consumed.duration_charging {
112 next.duration_charging = next
113 .duration_charging
114 .checked_add(&duration)
115 .unwrap_or(TimeDelta::MAX);
116 }
117
118 if let Some(energy) = consumed.energy {
119 next.energy = next.energy.saturating_add(energy);
120 }
121
122 next
123 }
124
125 fn local_time(&self) -> chrono::NaiveTime {
127 self.date_time.with_timezone(&self.local_timezone).time()
128 }
129
130 fn local_date(&self) -> chrono::NaiveDate {
132 self.date_time
133 .with_timezone(&self.local_timezone)
134 .date_naive()
135 }
136
137 fn local_weekday(&self) -> chrono::Weekday {
139 self.date_time.with_timezone(&self.local_timezone).weekday()
140 }
141}
142
143#[derive(Debug)]
146pub struct Report {
147 pub warnings: Vec<WarningReport>,
149
150 pub periods: Vec<PeriodReport>,
152
153 pub tariff: TariffReport,
155
156 pub tariff_reports: Vec<TariffReport>,
160
161 pub timezone: String,
163
164 pub billed_charging_time: Option<TimeDelta>,
167
168 pub billed_energy: Option<Kwh>,
170
171 pub billed_parking_time: Option<TimeDelta>,
173
174 pub total_charging_time: Option<TimeDelta>,
180
181 pub total_energy: Total<Kwh, Option<Kwh>>,
183
184 pub total_parking_time: Total<Option<TimeDelta>>,
186
187 pub total_time: Total<TimeDelta>,
189
190 pub total_cost: Total<Price, Option<Price>>,
193
194 pub total_energy_cost: Total<Option<Price>>,
196
197 pub total_fixed_cost: Total<Option<Price>>,
199
200 pub total_parking_cost: Total<Option<Price>>,
202
203 pub total_reservation_cost: Total<Option<Price>>,
205
206 pub total_time_cost: Total<Option<Price>>,
208}
209
210#[derive(Debug)]
212pub struct WarningReport {
213 pub kind: WarningKind,
215}
216
217#[derive(Debug)]
219pub enum WarningKind {
220 PeriodsOutsideStartEndDateTime {
223 cdr_range: Range<DateTime<Utc>>,
224 period_range: PeriodRange,
225 },
226}
227
228impl fmt::Display for WarningKind {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 match self {
231 Self::PeriodsOutsideStartEndDateTime {
232 cdr_range,
233 period_range,
234 } => {
235 write!(f, "The CDR's charging period time range is not contained within the `start_date_time` and `end_date_time`; cdr_range: {}-{}, period_range: {}", cdr_range.start, cdr_range.end, period_range)
236 }
237 }
238 }
239}
240
241impl warning::Kind for WarningKind {
242 fn id(&self) -> Cow<'static, str> {
243 match self {
244 WarningKind::PeriodsOutsideStartEndDateTime { .. } => {
245 "periods_outside_start_end_date_time".into()
246 }
247 }
248 }
249}
250
251#[derive(Debug)]
253pub struct TariffReport {
254 pub origin: TariffOrigin,
256
257 pub unexpected_fields: UnexpectedFields,
259}
260
261#[derive(Clone, Debug)]
263pub struct TariffOrigin {
264 pub index: usize,
266
267 pub id: String,
269}
270
271#[derive(Debug)]
273pub(crate) struct Period {
274 pub start_date_time: DateTime<Utc>,
276
277 pub consumed: Consumed,
279}
280
281#[derive(Debug)]
283pub struct Dimensions {
284 pub energy: Dimension<Kwh>,
286
287 pub flat: Dimension<()>,
289
290 pub duration_charging: Dimension<TimeDelta>,
292
293 pub duration_parking: Dimension<TimeDelta>,
295}
296
297impl Dimensions {
298 fn new(components: ComponentSet, consumed: &Consumed) -> Self {
299 Self {
300 energy: Dimension::new(components.energy, consumed.energy),
301 flat: Dimension::new(components.flat, Some(())),
302 duration_charging: Dimension::new(
303 components.duration_charging,
304 consumed.duration_charging,
305 ),
306 duration_parking: Dimension::new(
307 components.duration_parking,
308 consumed.duration_parking,
309 ),
310 }
311 }
312}
313
314#[derive(Debug)]
315pub struct Dimension<V> {
317 pub price: Option<Component>,
321
322 pub volume: Option<V>,
326
327 pub billed_volume: Option<V>,
335}
336
337impl<V> Dimension<V>
338where
339 V: Copy,
340{
341 fn new(price_component: Option<Component>, volume: Option<V>) -> Self {
342 Self {
343 price: price_component,
344 volume,
345 billed_volume: volume,
346 }
347 }
348}
349
350impl<V: Cost> Dimension<V> {
351 pub fn cost(&self) -> Option<Price> {
353 if let (Some(volume), Some(price)) = (&self.billed_volume, &self.price) {
354 let excl_vat = volume.cost(price.price);
355
356 let incl_vat = match price.vat {
357 VatApplicable::Applicable(vat) => Some(excl_vat.apply_vat(vat)),
358 VatApplicable::Inapplicable => Some(excl_vat),
359 VatApplicable::Unknown => None,
360 };
361
362 Some(Price { excl_vat, incl_vat })
363 } else {
364 None
365 }
366 }
367}
368
369#[derive(Debug)]
374pub struct ComponentSet {
375 pub energy: Option<Component>,
377
378 pub flat: Option<Component>,
380
381 pub duration_charging: Option<Component>,
383
384 pub duration_parking: Option<Component>,
386}
387
388impl ComponentSet {
389 fn new() -> Self {
390 Self {
391 energy: None,
392 flat: None,
393 duration_charging: None,
394 duration_parking: None,
395 }
396 }
397
398 fn has_all_components(&self) -> bool {
400 let Self {
401 energy,
402 flat,
403 duration_charging,
404 duration_parking,
405 } = self;
406
407 flat.is_some()
408 && energy.is_some()
409 && duration_parking.is_some()
410 && duration_charging.is_some()
411 }
412}
413
414#[derive(Clone, Debug)]
419pub struct Component {
420 pub tariff_element_index: usize,
422
423 pub price: Money,
425
426 pub vat: VatApplicable,
429
430 pub step_size: u64,
438}
439
440impl Component {
441 fn new(component: &v221::tariff::PriceComponent, tariff_element_index: usize) -> Self {
442 let v221::tariff::PriceComponent {
443 price,
444 vat,
445 step_size,
446 dimension_type: _,
447 } = component;
448
449 Self {
450 tariff_element_index,
451 price: *price,
452 vat: *vat,
453 step_size: *step_size,
454 }
455 }
456}
457
458#[derive(Debug)]
472pub struct Total<TCdr, TCalc = TCdr> {
473 pub cdr: TCdr,
475
476 pub calculated: TCalc,
478}
479
480#[derive(Debug)]
482pub enum Error {
483 Deserialize(ParseError),
485
486 DimensionShouldHaveVolume { dimension_name: &'static str },
488
489 DurationOverflow,
491
492 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
494
495 NoValidTariff,
505}
506
507impl From<InvalidPeriodIndex> for Error {
508 fn from(err: InvalidPeriodIndex) -> Self {
509 Self::Internal(err.into())
510 }
511}
512
513#[derive(Debug)]
514struct InvalidPeriodIndex(&'static str);
515
516impl std::error::Error for InvalidPeriodIndex {}
517
518impl fmt::Display for InvalidPeriodIndex {
519 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
520 write!(f, "Invalid index for period `{}`", self.0)
521 }
522}
523
524#[derive(Debug)]
526pub enum PeriodRange {
527 Many(Range<DateTime<Utc>>),
530
531 Single(DateTime<Utc>),
533}
534
535impl fmt::Display for PeriodRange {
536 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537 match self {
538 PeriodRange::Many(Range { start, end }) => write!(f, "{start}-{end}"),
539 PeriodRange::Single(date_time) => write!(f, "{date_time}"),
540 }
541 }
542}
543
544impl From<ParseError> for Error {
545 fn from(err: ParseError) -> Self {
546 Error::Deserialize(err)
547 }
548}
549
550impl From<duration::Error> for Error {
551 fn from(err: duration::Error) -> Self {
552 match err {
553 duration::Error::Overflow => Self::DurationOverflow,
554 }
555 }
556}
557
558impl std::error::Error for Error {
559 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
560 if let Error::Internal(err) = self {
561 Some(&**err)
562 } else {
563 None
564 }
565 }
566}
567
568impl fmt::Display for Error {
569 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
570 match self {
571 Self::Deserialize(err) => {
572 write!(f, "{err}")
573 }
574 Self::DimensionShouldHaveVolume { dimension_name } => {
575 write!(f, "Dimension `{dimension_name}` should have volume")
576 }
577 Self::DurationOverflow => {
578 f.write_str("A numeric overflow occurred while creating a duration")
579 }
580 Self::Internal(err) => {
581 write!(f, "Internal: {err}")
582 }
583 Self::NoValidTariff => {
584 f.write_str("No valid tariff has been found in the list of provided tariffs")
585 }
586 }
587 }
588}
589
590#[derive(Debug)]
591enum InternalError {
592 InvalidPeriodIndex {
593 index: usize,
594 field_name: &'static str,
595 },
596}
597
598impl std::error::Error for InternalError {}
599
600impl From<InternalError> for Error {
601 fn from(err: InternalError) -> Self {
602 Error::Internal(Box::new(err))
603 }
604}
605
606impl fmt::Display for InternalError {
607 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
608 match self {
609 InternalError::InvalidPeriodIndex { field_name, index } => {
610 write!(
611 f,
612 "Invalid period index for `{field_name}`; index: `{index}`"
613 )
614 }
615 }
616 }
617}
618
619#[derive(Debug)]
623pub enum TariffSource {
624 UseCdr,
626
627 Override(Vec<String>),
629}
630
631#[instrument(skip_all)]
632pub(super) fn cdr(
633 cdr: crate::cdr::Versioned<'_>,
634 tariff_source: TariffSource,
635 timezone: Tz,
636) -> Result<Report, Error> {
637 let version = cdr.version();
638 let cdr = cdr_from_str(&cdr)?;
639
640 match tariff_source {
641 TariffSource::UseCdr => {
642 debug!("Using tariffs from CDR");
643 let tariffs = cdr
644 .tariffs
645 .iter()
646 .map(|json| tariff::from_str(json.get(), version))
647 .collect::<Result<Vec<_>, _>>()?;
648 Ok(price_v221_cdr_with_tariffs(
649 cdr, tariffs, timezone, version,
650 )?)
651 }
652 TariffSource::Override(tariffs) => {
653 debug!("Using override tariffs");
654 let tariffs = tariffs
655 .iter()
656 .map(|json| tariff::from_str(json, version))
657 .collect::<Result<Vec<_>, _>>()?;
658 Ok(price_v221_cdr_with_tariffs(
659 cdr, tariffs, timezone, version,
660 )?)
661 }
662 }
663}
664
665fn price_v221_cdr_with_tariffs(
672 mut cdr: v221::Cdr<'_>,
673 tariffs: Vec<tariff::DeserOutcome>,
674 timezone: Tz,
675 version: Version,
676) -> Result<Report, Error> {
677 debug!(?timezone, ?version, "Pricing CDR");
678
679 let validation_warnings = validate_and_sanitize_cdr(&mut cdr);
680
681 let tariffs_normalized = tariff::normalize_all(tariffs);
682 debug!(tariffs = ?tariffs_normalized.iter().map(tariff::Normalized::id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
683
684 let (tariff_report, tariff) =
685 tariff::find_first_active(&tariffs_normalized, cdr.start_date_time)
686 .ok_or(Error::NoValidTariff)?;
687
688 debug!(ref = ?tariff_report.origin, "Found active tariff");
689 debug!(%timezone, "Found timezone");
690
691 let cs_periods = v221::cdr::normalize_periods(&mut cdr, timezone)?;
692 let price_cdr_report = price_periods(&cs_periods, tariff)?;
693
694 let tariff_reports = tariffs_normalized
695 .into_iter()
696 .map(TariffReport::from)
697 .collect::<Vec<_>>();
698
699 Ok(generate_report(
700 &cdr,
701 timezone,
702 validation_warnings,
703 tariff_reports,
704 price_cdr_report,
705 tariff_report,
706 ))
707}
708
709fn validate_and_sanitize_cdr(cdr: &mut v221::Cdr<'_>) -> warning::Set<WarningKind> {
713 let mut warnings = warning::Set::new();
714 let cdr_range = cdr.start_date_time..cdr.end_date_time;
715 cdr.charging_periods.sort_by_key(|p| p.start_date_time);
716
717 match cdr.charging_periods.as_slice() {
718 [] => (),
719 [period] => {
720 if !cdr_range.contains(&period.start_date_time) {
721 warnings.only_kind(WarningKind::PeriodsOutsideStartEndDateTime {
722 cdr_range,
723 period_range: PeriodRange::Single(period.start_date_time),
724 });
725 }
726 }
727 [period_earliest, .., period_latest] => {
728 let period_range = period_earliest.start_date_time..period_latest.start_date_time;
729
730 if !(cdr_range.contains(&period_range.start) && cdr_range.contains(&period_range.end)) {
731 warnings.only_kind(WarningKind::PeriodsOutsideStartEndDateTime {
732 cdr_range,
733 period_range: PeriodRange::Many(period_range),
734 });
735 }
736 }
737 }
738
739 warnings
740}
741
742#[allow(dead_code, reason = "Pending use in CDR generation")]
744pub(crate) fn periods(
745 end_date_time: DateTime<Utc>,
746 timezone: Tz,
747 tariff: &crate::tariff::Versioned<'_>,
748 periods: &mut [Period],
749) -> Result<PricePeriodsReport, Error> {
750 periods.sort_by_key(|p| p.start_date_time);
753 let mut out_periods = Vec::<PeriodNormalized>::new();
754
755 for (index, period) in periods.iter().enumerate() {
756 trace!(index, "processing\n{period:#?}");
757
758 let next_index = index + 1;
759
760 let end_date_time = if let Some(next_period) = periods.get(next_index) {
761 next_period.start_date_time
762 } else {
763 end_date_time
764 };
765
766 let next = if let Some(last) = out_periods.last() {
767 let start_snapshot = last.end_snapshot.clone();
768 let end_snapshot = start_snapshot.next(&period.consumed, end_date_time);
769
770 let period = PeriodNormalized {
771 consumed: period.consumed.clone(),
772 start_snapshot,
773 end_snapshot,
774 };
775 trace!("Adding new period based on the last added\n{period:#?}\n{last:#?}");
776 period
777 } else {
778 let start_snapshot = TotalsSnapshot::zero(period.start_date_time, timezone);
779 let end_snapshot = start_snapshot.next(&period.consumed, end_date_time);
780
781 let period = PeriodNormalized {
782 consumed: period.consumed.clone(),
783 start_snapshot,
784 end_snapshot,
785 };
786 trace!("Adding new period\n{period:#?}");
787 period
788 };
789
790 out_periods.push(next);
791 }
792
793 let tariff = tariff::from_str(tariff.as_json_str(), tariff.version())?;
794 let tariff::DeserOutcome {
795 tariff,
796 unexpected_fields: _,
798 } = tariff;
799 let tariff = Tariff::from_v221(&tariff);
800
801 price_periods(&out_periods, &tariff)
802}
803
804fn price_periods(
806 periods: &[PeriodNormalized],
807 tariff: &Tariff,
808) -> Result<PricePeriodsReport, Error> {
809 debug!(count = periods.len(), "Pricing CDR periods");
810
811 if tracing::enabled!(tracing::Level::TRACE) {
812 trace!("# CDR period list:");
813 for period in periods {
814 trace!("{period:#?}");
815 }
816 }
817
818 let period_totals = period_totals(periods, tariff);
819 let (billable, periods, totals) = period_totals.calculate_billed()?;
820 let total_costs = total_costs(&periods, tariff);
821
822 Ok(PricePeriodsReport {
823 billable,
824 periods,
825 totals,
826 total_costs,
827 })
828}
829
830pub(crate) struct PricePeriodsReport {
832 billable: Billable,
834
835 periods: Vec<PeriodReport>,
837
838 totals: Totals,
840
841 total_costs: TotalCosts,
843}
844
845#[derive(Debug)]
851pub struct PeriodReport {
852 pub start_date_time: DateTime<Utc>,
854
855 pub end_date_time: DateTime<Utc>,
857
858 pub dimensions: Dimensions,
860}
861
862impl PeriodReport {
863 fn new(period: &PeriodNormalized, dimensions: Dimensions) -> Self {
864 Self {
865 start_date_time: period.start_snapshot.date_time,
866 end_date_time: period.end_snapshot.date_time,
867 dimensions,
868 }
869 }
870
871 pub fn cost(&self) -> Option<Price> {
873 [
874 self.dimensions.duration_charging.cost(),
875 self.dimensions.duration_parking.cost(),
876 self.dimensions.flat.cost(),
877 self.dimensions.energy.cost(),
878 ]
879 .into_iter()
880 .fold(None, |accum, next| {
881 if accum.is_none() && next.is_none() {
882 None
883 } else {
884 Some(
885 accum
886 .unwrap_or_default()
887 .saturating_add(next.unwrap_or_default()),
888 )
889 }
890 })
891 }
892}
893
894struct PeriodTotals {
896 periods: Vec<PeriodReport>,
898
899 step_size: StepSize,
901
902 totals: Totals,
904}
905
906#[derive(Debug, Default)]
908struct Totals {
909 charging_time: Option<TimeDelta>,
911
912 energy: Option<Kwh>,
914
915 parking_time: Option<TimeDelta>,
917}
918
919impl PeriodTotals {
920 fn calculate_billed(self) -> Result<(Billable, Vec<PeriodReport>, Totals), Error> {
924 let Self {
925 mut periods,
926 step_size,
927 totals,
928 } = self;
929 let charging_time = totals
930 .charging_time
931 .map(|dt| step_size.apply_time(&mut periods, dt))
932 .transpose()?;
933 let energy = totals
934 .energy
935 .map(|kwh| step_size.apply_energy(&mut periods, kwh))
936 .transpose()?;
937 let parking_time = totals
938 .parking_time
939 .map(|dt| step_size.apply_parking_time(&mut periods, dt))
940 .transpose()?;
941 let billed = Billable {
942 charging_time,
943 energy,
944 parking_time,
945 };
946 Ok((billed, periods, totals))
947 }
948}
949
950#[derive(Debug)]
952pub(crate) struct Billable {
953 charging_time: Option<TimeDelta>,
955
956 energy: Option<Kwh>,
958
959 parking_time: Option<TimeDelta>,
961}
962
963fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
966 let mut has_flat_fee = false;
967 let mut step_size = StepSize::new();
968 let mut totals = Totals::default();
969
970 debug!(
971 tariff_id = tariff.id(),
972 period_count = periods.len(),
973 "Accumulating dimension totals for each period"
974 );
975
976 let periods = periods
977 .iter()
978 .enumerate()
979 .map(|(index, period)| {
980 let mut component_set = tariff.active_components(period);
981 trace!(
982 index,
983 "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
984 );
985
986 if component_set.flat.is_some() {
987 if has_flat_fee {
988 component_set.flat = None;
989 } else {
990 has_flat_fee = true;
991 }
992 }
993
994 step_size.update(index, &component_set, period);
995
996 trace!(period_index = index, "Step size updated\n{step_size:#?}");
997
998 let dimensions = Dimensions::new(component_set, &period.consumed);
999
1000 trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1001
1002 if let Some(dt) = dimensions.duration_charging.volume {
1003 let acc = totals.charging_time.get_or_insert_default();
1004 *acc = acc.saturating_add(dt);
1005 }
1006
1007 if let Some(kwh) = dimensions.energy.volume {
1008 let acc = totals.energy.get_or_insert_default();
1009 *acc = acc.saturating_add(kwh);
1010 }
1011
1012 if let Some(dt) = dimensions.duration_parking.volume {
1013 let acc = totals.parking_time.get_or_insert_default();
1014 *acc = acc.saturating_add(dt);
1015 }
1016
1017 trace!(period_index = index, ?totals, "Update totals");
1018
1019 PeriodReport::new(period, dimensions)
1020 })
1021 .collect::<Vec<_>>();
1022
1023 PeriodTotals {
1024 periods,
1025 step_size,
1026 totals,
1027 }
1028}
1029
1030#[derive(Debug, Default)]
1032pub(crate) struct TotalCosts {
1033 energy: Option<Price>,
1035
1036 fixed: Option<Price>,
1038
1039 duration_charging: Option<Price>,
1041
1042 duration_parking: Option<Price>,
1044}
1045
1046impl TotalCosts {
1047 fn total(&self) -> Option<Price> {
1051 let Self {
1052 energy,
1053 fixed,
1054 duration_charging,
1055 duration_parking,
1056 } = self;
1057 debug!(
1058 energy = %DisplayOption(*energy),
1059 fixed = %DisplayOption(*fixed),
1060 duration_charging = %DisplayOption(*duration_charging),
1061 duration_parking = %DisplayOption(*duration_parking),
1062 "Calculating total costs."
1063 );
1064 [energy, fixed, duration_charging, duration_parking]
1065 .into_iter()
1066 .fold(None, |accum: Option<Price>, next| match (accum, next) {
1067 (None, None) => None,
1068 _ => Some(
1069 accum
1070 .unwrap_or_default()
1071 .saturating_add(next.unwrap_or_default()),
1072 ),
1073 })
1074 }
1075}
1076
1077fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1079 let mut total_costs = TotalCosts::default();
1080
1081 debug!(
1082 tariff_id = tariff.id(),
1083 period_count = periods.len(),
1084 "Accumulating dimension costs for each period"
1085 );
1086 for (index, period) in periods.iter().enumerate() {
1087 let dimensions = &period.dimensions;
1088
1089 trace!(period_index = index, "Processing period");
1090
1091 let energy_cost = dimensions.energy.cost();
1092 let fixed_cost = dimensions.flat.cost();
1093 let duration_charging_cost = dimensions.duration_charging.cost();
1094 let duration_parking_cost = dimensions.duration_parking.cost();
1095
1096 trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1097 trace!(?total_costs.duration_charging, ?duration_charging_cost, "Energy cost");
1098 trace!(?total_costs.duration_parking, ?duration_parking_cost, "Energy cost");
1099 trace!(?total_costs.fixed, ?fixed_cost, "Energy cost");
1100
1101 total_costs.energy = match (total_costs.energy, energy_cost) {
1102 (None, None) => None,
1103 (total, period) => Some(
1104 total
1105 .unwrap_or_default()
1106 .saturating_add(period.unwrap_or_default()),
1107 ),
1108 };
1109
1110 total_costs.duration_charging =
1111 match (total_costs.duration_charging, duration_charging_cost) {
1112 (None, None) => None,
1113 (total, period) => Some(
1114 total
1115 .unwrap_or_default()
1116 .saturating_add(period.unwrap_or_default()),
1117 ),
1118 };
1119
1120 total_costs.duration_parking = match (total_costs.duration_parking, duration_parking_cost) {
1121 (None, None) => None,
1122 (total, period) => Some(
1123 total
1124 .unwrap_or_default()
1125 .saturating_add(period.unwrap_or_default()),
1126 ),
1127 };
1128
1129 total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1130 (None, None) => None,
1131 (total, period) => Some(
1132 total
1133 .unwrap_or_default()
1134 .saturating_add(period.unwrap_or_default()),
1135 ),
1136 };
1137
1138 trace!(period_index = index, ?total_costs, "Update totals");
1139 }
1140
1141 total_costs
1142}
1143
1144fn generate_report(
1145 cdr: &v221::Cdr<'_>,
1146 timezone: Tz,
1147 validation_warnings: warning::Set<WarningKind>,
1148 tariff_reports: Vec<TariffReport>,
1149 price_periods_report: PricePeriodsReport,
1150 tariff_report: TariffReport,
1151) -> Report {
1152 let PricePeriodsReport {
1153 billable,
1154 periods,
1155 totals,
1156 total_costs,
1157 } = price_periods_report;
1158 trace!("Update billed totals {billable:#?}");
1159
1160 let total_cost = total_costs.total();
1161
1162 debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1163
1164 let total_time = {
1165 debug!(
1166 period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1167 period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1168 "Calculating `total_time`"
1169 );
1170
1171 periods
1172 .first()
1173 .zip(periods.last())
1174 .map(|(first, last)| {
1175 last.end_date_time
1176 .signed_duration_since(first.start_date_time)
1177 })
1178 .unwrap_or_default()
1179 };
1180 debug!(total_time = %Hms(total_time));
1181
1182 let report = Report {
1183 periods,
1184 tariff: tariff_report,
1185 timezone: timezone.to_string(),
1186 billed_parking_time: billable.parking_time,
1187 billed_energy: billable.energy,
1188 billed_charging_time: billable.charging_time,
1189 tariff_reports,
1190 total_charging_time: totals.charging_time,
1191 total_cost: Total {
1192 cdr: cdr.total_cost,
1193 calculated: total_cost,
1194 },
1195 total_time_cost: Total {
1196 cdr: cdr.total_time_cost,
1197 calculated: total_costs.duration_charging,
1198 },
1199 total_time: Total {
1200 cdr: cdr.total_time,
1201 calculated: total_time,
1202 },
1203 total_parking_cost: Total {
1204 cdr: cdr.total_parking_cost,
1205 calculated: total_costs.duration_parking,
1206 },
1207 total_parking_time: Total {
1208 cdr: cdr.total_parking_time,
1209 calculated: totals.parking_time,
1210 },
1211 total_energy_cost: Total {
1212 cdr: cdr.total_energy_cost,
1213 calculated: total_costs.energy,
1214 },
1215 total_energy: Total {
1216 cdr: cdr.total_energy,
1217 calculated: totals.energy,
1218 },
1219 total_fixed_cost: Total {
1220 cdr: cdr.total_fixed_cost,
1221 calculated: total_costs.fixed,
1222 },
1223 total_reservation_cost: Total {
1224 cdr: cdr.total_reservation_cost,
1225 calculated: None,
1226 },
1227 warnings: validation_warnings
1228 .into_parts_vec()
1229 .into_iter()
1230 .map(|(kind, _elem_id)| {
1231 WarningReport { kind }
1233 })
1234 .collect(),
1235 };
1236
1237 trace!("{report:#?}");
1238
1239 report
1240}
1241
1242#[derive(Debug)]
1243struct StepSize {
1244 charging_time: Option<(usize, Component)>,
1245 parking_time: Option<(usize, Component)>,
1246 energy: Option<(usize, Component)>,
1247}
1248
1249fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1251 Decimal::from(delta.num_milliseconds())
1252 .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1253 .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1254}
1255
1256fn delta_from_seconds_dec(seconds: Decimal) -> Result<TimeDelta, duration::Error> {
1258 let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1259 let millis = i64::try_from(millis)?;
1260 let delta = TimeDelta::try_milliseconds(millis).ok_or(duration::Error::Overflow)?;
1261 Ok(delta)
1262}
1263
1264impl StepSize {
1265 fn new() -> Self {
1266 Self {
1267 charging_time: None,
1268 parking_time: None,
1269 energy: None,
1270 }
1271 }
1272
1273 fn update(&mut self, index: usize, components: &ComponentSet, period: &PeriodNormalized) {
1274 if period.consumed.energy.is_some() {
1275 if let Some(energy) = components.energy.clone() {
1276 self.energy = Some((index, energy));
1277 }
1278 }
1279
1280 if period.consumed.duration_charging.is_some() {
1281 if let Some(time) = components.duration_charging.clone() {
1282 self.charging_time = Some((index, time));
1283 }
1284 }
1285
1286 if period.consumed.duration_parking.is_some() {
1287 if let Some(parking) = components.duration_parking.clone() {
1288 self.parking_time = Some((index, parking));
1289 }
1290 }
1291 }
1292
1293 fn duration_step_size(
1294 total_volume: TimeDelta,
1295 period_billed_volume: &mut TimeDelta,
1296 step_size: u64,
1297 ) -> Result<TimeDelta, Error> {
1298 if step_size == 0 {
1299 return Ok(total_volume);
1300 }
1301
1302 let total_seconds = delta_as_seconds_dec(total_volume);
1303 let step_size = Decimal::from(step_size);
1304
1305 let total_billed_volume = delta_from_seconds_dec(
1306 total_seconds
1307 .checked_div(step_size)
1308 .ok_or(Error::DurationOverflow)?
1309 .ceil()
1310 .saturating_mul(step_size),
1311 )?;
1312
1313 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1314 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1315
1316 Ok(total_billed_volume)
1317 }
1318
1319 fn apply_time(
1320 &self,
1321 periods: &mut [PeriodReport],
1322 total: TimeDelta,
1323 ) -> Result<TimeDelta, Error> {
1324 let (Some((time_index, price)), None) = (&self.charging_time, &self.parking_time) else {
1325 return Ok(total);
1326 };
1327
1328 let Some(period) = periods.get_mut(*time_index) else {
1329 return Err(InternalError::InvalidPeriodIndex {
1330 index: *time_index,
1331 field_name: "apply_time",
1332 }
1333 .into());
1334 };
1335 let volume = period
1336 .dimensions
1337 .duration_charging
1338 .billed_volume
1339 .as_mut()
1340 .ok_or(Error::DimensionShouldHaveVolume {
1341 dimension_name: "time",
1342 })?;
1343
1344 Self::duration_step_size(total, volume, price.step_size)
1345 }
1346
1347 fn apply_parking_time(
1348 &self,
1349 periods: &mut [PeriodReport],
1350 total: TimeDelta,
1351 ) -> Result<TimeDelta, Error> {
1352 let Some((parking_index, price)) = &self.parking_time else {
1353 return Ok(total);
1354 };
1355
1356 let Some(period) = periods.get_mut(*parking_index) else {
1357 return Err(InternalError::InvalidPeriodIndex {
1358 index: *parking_index,
1359 field_name: "apply_parking_time",
1360 }
1361 .into());
1362 };
1363 let volume = period
1364 .dimensions
1365 .duration_parking
1366 .billed_volume
1367 .as_mut()
1368 .ok_or(Error::DimensionShouldHaveVolume {
1369 dimension_name: "parking_time",
1370 })?;
1371
1372 Self::duration_step_size(total, volume, price.step_size)
1373 }
1374
1375 fn apply_energy(&self, periods: &mut [PeriodReport], total_volume: Kwh) -> Result<Kwh, Error> {
1376 let Some((energy_index, price)) = &self.energy else {
1377 return Ok(total_volume);
1378 };
1379
1380 if price.step_size == 0 {
1381 return Ok(total_volume);
1382 }
1383
1384 let Some(period) = periods.get_mut(*energy_index) else {
1385 return Err(InternalError::InvalidPeriodIndex {
1386 index: *energy_index,
1387 field_name: "apply_energy",
1388 }
1389 .into());
1390 };
1391 let step_size = Decimal::from(price.step_size);
1392
1393 let period_billed_volume = period.dimensions.energy.billed_volume.as_mut().ok_or(
1394 Error::DimensionShouldHaveVolume {
1395 dimension_name: "energy",
1396 },
1397 )?;
1398
1399 let total_billed_volume = Kwh::from_watt_hours(
1400 total_volume
1401 .watt_hours()
1402 .checked_div(step_size)
1403 .ok_or(Error::DurationOverflow)?
1404 .ceil()
1405 .saturating_mul(step_size),
1406 );
1407
1408 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1409 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1410
1411 Ok(total_billed_volume)
1412 }
1413}
1414
1415fn cdr_from_str<'buf>(cdr: &crate::cdr::Versioned<'buf>) -> Result<v221::Cdr<'buf>, ParseError> {
1416 match cdr.version() {
1417 Version::V221 => {
1418 let cdr = serde_json::from_str::<v221::Cdr<'_>>(cdr.as_json_str())
1419 .map_err(ParseError::from_cdr_serde_err)?;
1420 Ok(cdr)
1421 }
1422 Version::V211 => {
1423 let cdr = serde_json::from_str::<v211::Cdr<'_>>(cdr.as_json_str())
1424 .map_err(ParseError::from_cdr_serde_err)?;
1425 Ok(cdr.into())
1426 }
1427 }
1428}
1429
1430#[cfg(test)]
1431pub mod test {
1432 #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
1433 #![allow(clippy::panic, reason = "tests are allowed panic")]
1434
1435 use std::collections::BTreeMap;
1436
1437 use chrono::TimeDelta;
1438 use serde::Deserialize;
1439 use tracing::debug;
1440
1441 use crate::{
1442 assert_approx_eq,
1443 duration::ToHoursDecimal,
1444 json,
1445 test::{ApproxEq, Expectation},
1446 timezone,
1447 warning::Kind,
1448 Kwh, Price, UnexpectedFields,
1449 };
1450
1451 use super::{de, Error, Report, TariffReport, Total};
1452
1453 const PRECISION: u32 = 2;
1455
1456 #[test]
1457 const fn error_should_be_send_and_sync() {
1458 const fn f<T: Send + Sync>() {}
1459
1460 f::<Error>();
1461 }
1462
1463 #[track_caller]
1465 pub fn parse_expect_json(expect_json: Option<&str>) -> Expect {
1466 expect_json
1467 .map(|json| serde_json::from_str(json).expect("Unable to parse expect JSON"))
1468 .unwrap_or_default()
1469 }
1470
1471 #[derive(serde::Deserialize, Default)]
1472 pub struct Expect {
1473 pub timezone_find: Option<timezone::test::FindOrInferExpect>,
1475
1476 pub cdr_parse: Option<ParseExpect>,
1478
1479 pub cdr_price: Option<PriceExpect>,
1481 }
1482
1483 pub(crate) fn assert_parse_report(
1484 unexpected_fields: json::UnexpectedFields<'_>,
1485 cdr_price_expect: Option<ParseExpect>,
1486 ) {
1487 let unexpected_fields_expect = cdr_price_expect
1488 .map(|exp| {
1489 let ParseExpect { unexpected_fields } = exp;
1490 unexpected_fields
1491 })
1492 .unwrap_or(Expectation::Absent);
1493
1494 if let Expectation::Present(expectation) = unexpected_fields_expect {
1495 let unexpected_fields_expect = expectation.expect_value();
1496
1497 for field in unexpected_fields {
1498 assert!(
1499 unexpected_fields_expect.contains(&field.to_string()),
1500 "The CDR has an unexpected field that's not expected: `{field}`"
1501 );
1502 }
1503 } else {
1504 assert!(
1505 unexpected_fields.is_empty(),
1506 "The CDR has unexpected fields; {unexpected_fields:#}",
1507 );
1508 }
1509 }
1510
1511 pub(crate) fn assert_price_report(report: Report, cdr_price_expect: Option<PriceExpect>) {
1512 let Report {
1513 warnings,
1514 mut tariff_reports,
1515 periods: _,
1516 tariff,
1517 timezone: _,
1518 billed_energy: _,
1519 billed_parking_time: _,
1520 billed_charging_time: _,
1521 total_charging_time: _,
1522 total_cost,
1523 total_fixed_cost,
1524 total_time,
1525 total_time_cost,
1526 total_energy,
1527 total_energy_cost,
1528 total_parking_time,
1529 total_parking_cost,
1530 total_reservation_cost,
1531 } = report;
1532
1533 let (
1536 warnings_expect,
1537 tariff_index_expect,
1538 tariff_id_expect,
1539 tariff_reports_expect,
1540 total_cost_expectation,
1541 total_fixed_cost_expectation,
1542 total_time_expectation,
1543 total_time_cost_expectation,
1544 total_energy_expectation,
1545 total_energy_cost_expectation,
1546 total_parking_time_expectation,
1547 total_parking_cost_expectation,
1548 total_reservation_cost_expectation,
1549 ) = cdr_price_expect
1550 .map(|exp| {
1551 let PriceExpect {
1552 warnings,
1553 tariff_index,
1554 tariff_id,
1555 tariff_reports,
1556 total_cost,
1557 total_fixed_cost,
1558 total_time,
1559 total_time_cost,
1560 total_energy,
1561 total_energy_cost,
1562 total_parking_time,
1563 total_parking_cost,
1564 total_reservation_cost,
1565 } = exp;
1566
1567 (
1568 warnings,
1569 tariff_index,
1570 tariff_id,
1571 tariff_reports,
1572 total_cost,
1573 total_fixed_cost,
1574 total_time,
1575 total_time_cost,
1576 total_energy,
1577 total_energy_cost,
1578 total_parking_time,
1579 total_parking_cost,
1580 total_reservation_cost,
1581 )
1582 })
1583 .unwrap_or((
1584 Expectation::Absent,
1585 Expectation::Absent,
1586 Expectation::Absent,
1587 Expectation::Absent,
1588 Expectation::Absent,
1589 Expectation::Absent,
1590 Expectation::Absent,
1591 Expectation::Absent,
1592 Expectation::Absent,
1593 Expectation::Absent,
1594 Expectation::Absent,
1595 Expectation::Absent,
1596 Expectation::Absent,
1597 ));
1598
1599 if let Expectation::Present(expectation) = warnings_expect {
1600 let warnings_expect = expectation.expect_value();
1601
1602 debug!("{warnings_expect:?}");
1603
1604 for warning in warnings {
1605 assert!(
1606 warnings_expect.contains(&warning.kind.id().to_string()),
1607 "The CDR has a warning that's not expected"
1608 );
1609 }
1610 } else {
1611 assert!(warnings.is_empty(), "The CDR has warnings; {warnings:?}",);
1612 }
1613
1614 if let Expectation::Present(expectation) = tariff_reports_expect {
1615 let tariff_reports_expect: BTreeMap<_, _> = expectation
1616 .expect_value()
1617 .into_iter()
1618 .map(
1619 |TariffReportExpect {
1620 id,
1621 unexpected_fields,
1622 }| (id, unexpected_fields),
1623 )
1624 .collect();
1625
1626 for report in &mut tariff_reports {
1627 let TariffReport {
1628 origin: reference,
1629 unexpected_fields,
1630 } = report;
1631 let id = &reference.id;
1632 let Some(unexpected_fields_expect) = tariff_reports_expect.get(id) else {
1633 panic!("A tariff with {id} is not expected");
1634 };
1635
1636 debug!("{:?}", unexpected_fields_expect);
1637
1638 unexpected_fields.retain(|field| {
1639 let present = unexpected_fields_expect.contains(field);
1640 assert!(present, "The tariff with id: `{id}` has an unexpected field that is not expected: `{field}`");
1641 !present
1642 });
1643
1644 assert!(
1645 unexpected_fields.is_empty(),
1646 "The tariff with id `{id}` has unexpected fields; {unexpected_fields:?}",
1647 );
1648 }
1649 } else {
1650 for report in &tariff_reports {
1651 let TariffReport {
1652 origin: reference,
1653 unexpected_fields,
1654 } = report;
1655 let id = &reference.id;
1656 assert!(
1657 unexpected_fields.is_empty(),
1658 "The tariff with id `{id}` has unexpected fields; {unexpected_fields:?}",
1659 );
1660 }
1661 }
1662
1663 if let Expectation::Present(expectation) = tariff_id_expect {
1664 assert_eq!(tariff.origin.id, expectation.expect_value());
1665 }
1666
1667 if let Expectation::Present(expectation) = tariff_index_expect {
1668 assert_eq!(tariff.origin.index, expectation.expect_value());
1669 }
1670
1671 total_cost_expectation.expect_price("total_cost", &total_cost);
1672 total_fixed_cost_expectation.expect_opt_price("total_fixed_cost", &total_fixed_cost);
1673 total_time_expectation.expect_duration("total_time", &total_time);
1674 total_time_cost_expectation.expect_opt_price("total_time_cost", &total_time_cost);
1675 total_energy_expectation.expect_opt_kwh("total_energy", &total_energy);
1676 total_energy_cost_expectation.expect_opt_price("total_energy_cost", &total_energy_cost);
1677 total_parking_time_expectation
1678 .expect_opt_duration("total_parking_time", &total_parking_time);
1679 total_parking_cost_expectation.expect_opt_price("total_parking_cost", &total_parking_cost);
1680 total_reservation_cost_expectation
1681 .expect_opt_price("total_reservation_cost", &total_reservation_cost);
1682 }
1683
1684 #[derive(serde::Deserialize)]
1686 pub struct ParseExpect {
1687 #[serde(default)]
1688 unexpected_fields: Expectation<Vec<String>>,
1689 }
1690
1691 #[derive(serde::Deserialize)]
1693 pub struct PriceExpect {
1694 #[serde(default)]
1695 warnings: Expectation<Vec<String>>,
1696
1697 #[serde(default)]
1699 tariff_index: Expectation<usize>,
1700
1701 #[serde(default)]
1703 tariff_id: Expectation<String>,
1704
1705 #[serde(default)]
1709 tariff_reports: Expectation<Vec<TariffReportExpect>>,
1710
1711 #[serde(default)]
1713 total_cost: Expectation<Price>,
1714
1715 #[serde(default)]
1717 total_fixed_cost: Expectation<Price>,
1718
1719 #[serde(default)]
1721 total_time: Expectation<de::HoursDecimal>,
1722
1723 #[serde(default)]
1725 total_time_cost: Expectation<Price>,
1726
1727 #[serde(default)]
1729 total_energy: Expectation<Kwh>,
1730
1731 #[serde(default)]
1733 total_energy_cost: Expectation<Price>,
1734
1735 #[serde(default)]
1737 total_parking_time: Expectation<de::HoursDecimal>,
1738
1739 #[serde(default)]
1741 total_parking_cost: Expectation<Price>,
1742
1743 #[serde(default)]
1745 total_reservation_cost: Expectation<Price>,
1746 }
1747
1748 #[derive(Debug, Deserialize)]
1749 struct TariffReportExpect {
1750 id: String,
1752
1753 unexpected_fields: UnexpectedFields,
1755 }
1756
1757 impl Expectation<Price> {
1758 #[track_caller]
1759 fn expect_opt_price(self, field_name: &str, total: &Total<Option<Price>>) {
1760 if let Expectation::Present(expect_value) = self {
1761 match (expect_value.into_option(), total.calculated) {
1762 (Some(a), Some(b)) => assert!(
1763 a.approx_eq(&b),
1764 "Expected `{a}` but `{b}` was calculated for `{field_name}`"
1765 ),
1766 (Some(a), None) => {
1767 panic!("Expected `{a}`, but no price was calculated for `{field_name}`")
1768 }
1769 (None, Some(b)) => {
1770 panic!("Expected no value, but `{b}` was calculated for `{field_name}`")
1771 }
1772 (None, None) => (),
1773 }
1774 } else {
1775 match (total.cdr, total.calculated) {
1776 (None, None) => (),
1777 (None, Some(calculated)) => {
1778 assert!(calculated.is_zero(), "The CDR field `{field_name}` doesn't have a value but a value was calculated; calculated: {calculated}");
1779 }
1780 (Some(cdr), None) => {
1781 assert!(
1782 cdr.is_zero(),
1783 "The CDR field `{field_name}` has a value but the calculated value is none; cdr: {cdr}"
1784 );
1785 }
1786 (Some(cdr), Some(calculated)) => {
1787 assert!(
1788 cdr.approx_eq(&calculated),
1789 "Comparing `{field_name}` field with CDR"
1790 );
1791 }
1792 }
1793 }
1794 }
1795
1796 #[track_caller]
1797 fn expect_price(self, field_name: &str, total: &Total<Price, Option<Price>>) {
1798 if let Expectation::Present(expect_value) = self {
1799 match (expect_value.into_option(), total.calculated) {
1800 (Some(a), Some(b)) => assert!(
1801 a.approx_eq(&b),
1802 "Expected `{a}` but `{b}` was calculated for `{field_name}`"
1803 ),
1804 (Some(a), None) => {
1805 panic!("Expected `{a}`, but no price was calculated for `{field_name}`")
1806 }
1807 (None, Some(b)) => {
1808 panic!("Expected no value, but `{b}` was calculated for `{field_name}`")
1809 }
1810 (None, None) => (),
1811 }
1812 } else if let Some(calculated) = total.calculated {
1813 assert!(
1814 total.cdr.approx_eq(&calculated),
1815 "CDR contains `{}` but `{}` was calculated for `{field_name}`",
1816 total.cdr,
1817 calculated
1818 );
1819 } else {
1820 assert!(
1821 total.cdr.is_zero(),
1822 "The CDR field `{field_name}` has a value but the calculated value is none; cdr: {:?}",
1823 total.cdr
1824 );
1825 }
1826 }
1827 }
1828
1829 impl Expectation<de::HoursDecimal> {
1830 #[track_caller]
1831 fn expect_duration(self, field_name: &str, total: &Total<TimeDelta>) {
1832 if let Expectation::Present(expect_value) = self {
1833 assert_approx_eq!(
1834 expect_value.expect_value().to_hours_dec(),
1835 total.calculated.to_hours_dec(),
1836 "Comparing `{field_name}` field with expectation"
1837 );
1838 } else {
1839 assert_approx_eq!(
1840 total.cdr.to_hours_dec(),
1841 total.calculated.to_hours_dec(),
1842 "Comparing `{field_name}` field with CDR"
1843 );
1844 }
1845 }
1846
1847 #[track_caller]
1848 fn expect_opt_duration(
1849 self,
1850 field_name: &str,
1851 total: &Total<Option<TimeDelta>, Option<TimeDelta>>,
1852 ) {
1853 if let Expectation::Present(expect_value) = self {
1854 assert_approx_eq!(
1855 expect_value
1856 .into_option()
1857 .unwrap_or_default()
1858 .to_hours_dec(),
1859 &total
1860 .calculated
1861 .as_ref()
1862 .map(ToHoursDecimal::to_hours_dec)
1863 .unwrap_or_default(),
1864 "Comparing `{field_name}` field with expectation"
1865 );
1866 } else {
1867 assert_approx_eq!(
1868 total.cdr.unwrap_or_default().to_hours_dec(),
1869 total.calculated.unwrap_or_default().to_hours_dec(),
1870 "Comparing `{field_name}` field with CDR"
1871 );
1872 }
1873 }
1874 }
1875
1876 impl Expectation<Kwh> {
1877 #[track_caller]
1878 fn expect_opt_kwh(self, field_name: &str, total: &Total<Kwh, Option<Kwh>>) {
1879 if let Expectation::Present(expect_value) = self {
1880 assert_eq!(
1881 expect_value
1882 .into_option()
1883 .map(|kwh| kwh.round_dp(PRECISION)),
1884 total
1885 .calculated
1886 .map(|kwh| kwh.rescale().round_dp(PRECISION)),
1887 "Comparing `{field_name}` field with expectation"
1888 );
1889 } else {
1890 assert_eq!(
1891 total.cdr.round_dp(PRECISION),
1892 total
1893 .calculated
1894 .map(|kwh| kwh.rescale().round_dp(PRECISION))
1895 .unwrap_or_default(),
1896 "Comparing `{field_name}` field with CDR"
1897 );
1898 }
1899 }
1900 }
1901}
1902
1903#[cfg(test)]
1904mod test_periods {
1905 #![allow(clippy::as_conversions, reason = "tests are allowed to panic")]
1906 #![allow(clippy::panic, reason = "tests are allowed panic")]
1907
1908 use chrono::Utc;
1909 use chrono_tz::Tz;
1910 use rust_decimal::Decimal;
1911 use rust_decimal_macros::dec;
1912
1913 use crate::{assert_approx_eq, cdr, price, Kwh};
1914
1915 use super::{Consumed, Period, PricePeriodsReport, TariffSource};
1916
1917 #[test]
1918 fn should_price_periods_from_time_and_parking_time_cdr_and_tariff() {
1919 const CDR_JSON: &str = include_str!(
1920 "../test_data/v211/real_world/time_and_parking_time_separate_tariff/cdr.json"
1921 );
1922 const TARIFF_JSON: &str = include_str!(
1923 "../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json"
1924 );
1925 const PERIOD_DURATION: chrono::TimeDelta = chrono::TimeDelta::minutes(15);
1927
1928 fn charging(start_date_time: &str, energy: Vec<Decimal>) -> Vec<Period> {
1933 let start: chrono::DateTime<Utc> = start_date_time.parse().unwrap();
1934
1935 energy
1936 .into_iter()
1937 .enumerate()
1938 .map(|(i, kwh)| {
1939 let i = i32::try_from(i).unwrap();
1940 let start_date_time = start + (PERIOD_DURATION * i);
1941
1942 Period {
1943 start_date_time,
1944 consumed: Consumed {
1945 duration_charging: Some(PERIOD_DURATION),
1946 energy: Some(kwh.into()),
1947 ..Default::default()
1948 },
1949 }
1950 })
1951 .collect()
1952 }
1953
1954 fn parking(start_date_time: &str, period_count: usize) -> Vec<Period> {
1959 let period_energy = Kwh::from(0);
1961 let start: chrono::DateTime<Utc> = start_date_time.parse().unwrap();
1962
1963 let period_count = i32::try_from(period_count).unwrap();
1964 let mut periods: Vec<Period> = (0..period_count - 1)
1966 .map(|i| {
1967 let start_date_time = start + (PERIOD_DURATION * i);
1968
1969 Period {
1970 start_date_time,
1971 consumed: Consumed {
1972 duration_parking: Some(PERIOD_DURATION),
1973 energy: Some(period_energy),
1974 ..Default::default()
1975 },
1976 }
1977 })
1978 .collect();
1979
1980 let start_date_time = start + (PERIOD_DURATION * (period_count - 1));
1981
1982 periods.push(Period {
1984 start_date_time,
1985 consumed: Consumed {
1986 duration_parking: Some(chrono::TimeDelta::seconds(644)),
1987 energy: Some(period_energy),
1988 ..Default::default()
1989 },
1990 });
1991
1992 periods
1993 }
1994
1995 let report = cdr::parse_with_version(CDR_JSON, crate::Version::V211).unwrap();
1996 let cdr::ParseReport {
1997 cdr,
1998 unexpected_fields,
1999 } = report;
2000
2001 assert!(unexpected_fields.is_empty());
2002 let report = cdr::price(
2004 cdr,
2005 TariffSource::Override(vec![TARIFF_JSON.to_string()]),
2006 Tz::Europe__Amsterdam,
2007 )
2008 .expect("unable to price CDR JSON");
2009
2010 let price::Report {
2011 warnings: _,
2013 periods,
2014 tariff: _,
2016 tariff_reports: _,
2017 timezone: _,
2018 billed_energy,
2019 billed_parking_time,
2020 billed_charging_time,
2021 total_charging_time,
2022 total_energy,
2023 total_parking_time,
2024 total_time: _,
2026 total_cost,
2027 total_energy_cost,
2028 total_fixed_cost,
2029 total_parking_cost,
2030 total_reservation_cost: _,
2032 total_time_cost,
2033 } = report;
2034
2035 let tariff = crate::tariff::parse(TARIFF_JSON).unwrap();
2036 let tariff = tariff.unwrap_certain();
2037
2038 let mut cdr_periods = charging(
2039 "2025-04-09T16:12:54.000Z",
2040 vec![
2041 dec!(2.75),
2042 dec!(2.77),
2043 dec!(1.88),
2044 dec!(2.1),
2045 dec!(2.09),
2046 dec!(2.11),
2047 dec!(2.09),
2048 dec!(2.09),
2049 dec!(2.09),
2050 dec!(2.09),
2051 dec!(2.09),
2052 dec!(2.09),
2053 dec!(2.09),
2054 dec!(2.11),
2055 dec!(2.13),
2056 dec!(2.09),
2057 dec!(2.11),
2058 dec!(2.12),
2059 dec!(2.13),
2060 dec!(2.1),
2061 dec!(2.0),
2062 dec!(0.69),
2063 dec!(0.11),
2064 ],
2065 );
2066 let mut periods_parking = parking("2025-04-09T21:57:55.000Z", 47);
2067
2068 cdr_periods.append(&mut periods_parking);
2069 cdr_periods.sort_by_key(|p| p.start_date_time);
2070
2071 assert_eq!(
2072 cdr_periods.len(),
2073 periods.len(),
2074 "The amount of `price::Report` periods should equal the periods given to the `price::periods` fn"
2075 );
2076 assert_eq!(
2077 periods.len(),
2078 70,
2079 "The `time_and_parking/cdr.json` has 70 `charging_periods`"
2080 );
2081
2082 assert!(periods
2083 .iter()
2084 .map(|p| p.start_date_time)
2085 .collect::<Vec<_>>()
2086 .is_sorted());
2087
2088 let periods_report = price::periods(
2089 "2025-04-10T09:38:38.000Z".parse().unwrap(),
2090 chrono_tz::Europe::Amsterdam,
2091 &tariff,
2092 &mut cdr_periods,
2093 )
2094 .unwrap();
2095
2096 let PricePeriodsReport {
2097 billable,
2098 periods,
2099 totals,
2100 total_costs,
2101 } = periods_report;
2102
2103 assert_eq!(
2104 cdr_periods.len(),
2105 periods.len(),
2106 "The amount of `price::Report` periods should equal the periods given to the `price::periods` fn"
2107 );
2108 assert_eq!(
2109 periods.len(),
2110 70,
2111 "The `time_and_parking/cdr.json` has 70 `charging_periods`"
2112 );
2113
2114 assert_approx_eq!(billable.charging_time, billed_charging_time);
2115 assert_approx_eq!(billable.energy, billed_energy);
2116 assert_approx_eq!(billable.parking_time, billed_parking_time,);
2117
2118 assert_approx_eq!(totals.charging_time, total_charging_time);
2119 assert_approx_eq!(totals.energy, total_energy.calculated);
2120 assert_approx_eq!(totals.parking_time, total_parking_time.calculated);
2121
2122 assert_approx_eq!(total_costs.duration_charging, total_time_cost.calculated,);
2123 assert_approx_eq!(total_costs.energy, total_energy_cost.calculated,);
2124 assert_approx_eq!(total_costs.fixed, total_fixed_cost.calculated);
2125 assert_approx_eq!(total_costs.duration_parking, total_parking_cost.calculated);
2126 assert_approx_eq!(total_costs.total(), total_cost.calculated);
2127 }
2128}
2129
2130#[cfg(test)]
2131mod test_validate_cdr {
2132 use assert_matches::assert_matches;
2133
2134 use crate::{
2135 price::{self, v221, WarningKind},
2136 test::{self, datetime_from_str},
2137 };
2138
2139 use super::validate_and_sanitize_cdr;
2140
2141 #[test]
2142 fn should_pass_validation() {
2143 test::setup();
2144 let json = cdr_json("2022-01-13T16:00:00Z", "2022-01-13T19:12:00Z");
2145 let mut cdr = serde_json::from_str::<v221::Cdr<'_>>(&json).unwrap();
2146
2147 let warnings = validate_and_sanitize_cdr(&mut cdr);
2148 assert!(warnings.is_empty());
2149 }
2150
2151 #[test]
2152 fn should_fail_validation_start_end_range_doesnt_overlap_with_periods() {
2153 test::setup();
2154
2155 let json = cdr_json("2022-02-13T16:00:00Z", "2022-02-13T19:12:00Z");
2156 let mut cdr = serde_json::from_str::<v221::Cdr<'_>>(&json).unwrap();
2157
2158 let warnings = validate_and_sanitize_cdr(&mut cdr).into_kind_vec();
2159 let [warning] = warnings.try_into().unwrap();
2160 let (cdr_range, period_range) = assert_matches!(warning, WarningKind::PeriodsOutsideStartEndDateTime { cdr_range, period_range } => (cdr_range, period_range));
2161
2162 {
2163 assert_eq!(cdr_range.start, datetime_from_str("2022-02-13T16:00:00Z"));
2164 assert_eq!(cdr_range.end, datetime_from_str("2022-02-13T19:12:00Z"));
2165 }
2166 {
2167 let period_range =
2168 assert_matches!(period_range, price::PeriodRange::Many(range) => range);
2169
2170 assert_eq!(
2171 period_range.start,
2172 datetime_from_str("2022-01-13T16:00:00Z")
2173 );
2174 assert_eq!(period_range.end, datetime_from_str("2022-01-13T18:30:00Z"));
2175 }
2176 }
2177
2178 fn cdr_json(start_date_time: &str, end_date_time: &str) -> String {
2179 let value = serde_json::json!({
2180 "start_date_time": start_date_time,
2181 "end_date_time": end_date_time,
2182 "currency": "EUR",
2183 "tariffs": [],
2184 "cdr_location": {
2185 "country": "NLD"
2186 },
2187 "charging_periods": [
2188 {
2189 "start_date_time": "2022-01-13T16:00:00Z",
2190 "dimensions": [
2191 {
2192 "type": "TIME",
2193 "volume": 2.5
2194 }
2195 ]
2196 },
2197 {
2198 "start_date_time": "2022-01-13T18:30:00Z",
2199 "dimensions": [
2200 {
2201 "type": "PARKING_TIME",
2202 "volume": 0.7
2203 }
2204 ]
2205 }
2206 ],
2207 "total_cost": {
2208 "excl_vat": 11.25,
2209 "incl_vat": 12.75
2210 },
2211 "total_time_cost": {
2212 "excl_vat": 7.5,
2213 "incl_vat": 8.25
2214 },
2215 "total_parking_time": 0.7,
2216 "total_parking_cost": {
2217 "excl_vat": 3.75,
2218 "incl_vat": 4.5
2219 },
2220 "total_time": 3.2,
2221 "total_energy": 0,
2222 "last_updated": "2022-01-13T00:00:00Z"
2223 });
2224
2225 value.to_string()
2226 }
2227}