1mod tariff;
4mod v211;
5mod v221;
6
7use std::{borrow::Cow, collections::BTreeMap, fmt, ops::Range};
8
9use chrono::{DateTime, Datelike, TimeDelta, Utc};
10use chrono_tz::Tz;
11use rust_decimal::Decimal;
12use tracing::{debug, instrument, trace};
13
14use crate::{
15 country, currency, datetime,
16 duration::{self, Hms},
17 from_warning_set_to,
18 json::{self, FromJson as _},
19 money,
20 number::{self, FromDecimal},
21 string,
22 warning::{self, IntoCaveat},
23 weekday, Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, ParseError, Price,
24 SaturatingAdd as _, SaturatingSub as _, VatApplicable, Verdict, VerdictExt as _, Version,
25 Versioned as _,
26};
27
28use tariff::Tariff;
29
30pub type WarningMap = BTreeMap<String, Vec<WarningKind>>;
31
32#[derive(Debug)]
37struct PeriodNormalized {
38 consumed: Consumed,
40
41 start_snapshot: TotalsSnapshot,
43
44 end_snapshot: TotalsSnapshot,
46}
47
48#[derive(Clone, Debug)]
50#[cfg_attr(test, derive(Default))]
51pub(crate) struct Consumed {
52 pub current_max: Option<Ampere>,
54
55 pub current_min: Option<Ampere>,
57
58 pub duration_charging: Option<TimeDelta>,
60
61 pub duration_parking: Option<TimeDelta>,
63
64 pub energy: Option<Kwh>,
66
67 pub power_max: Option<Kw>,
69
70 pub power_min: Option<Kw>,
72}
73
74#[derive(Clone, Debug)]
76struct TotalsSnapshot {
77 date_time: DateTime<Utc>,
79
80 energy: Kwh,
82
83 local_timezone: Tz,
85
86 duration_charging: TimeDelta,
88
89 duration_total: TimeDelta,
91}
92
93impl TotalsSnapshot {
94 fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
96 Self {
97 date_time,
98 energy: Kwh::zero(),
99 local_timezone,
100 duration_charging: TimeDelta::zero(),
101 duration_total: TimeDelta::zero(),
102 }
103 }
104
105 fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
107 let duration = date_time.signed_duration_since(self.date_time);
108
109 let mut next = Self {
110 date_time,
111 energy: self.energy,
112 local_timezone: self.local_timezone,
113 duration_charging: self.duration_charging,
114 duration_total: self
115 .duration_total
116 .checked_add(&duration)
117 .unwrap_or(TimeDelta::MAX),
118 };
119
120 if let Some(duration) = consumed.duration_charging {
121 next.duration_charging = next
122 .duration_charging
123 .checked_add(&duration)
124 .unwrap_or(TimeDelta::MAX);
125 }
126
127 if let Some(energy) = consumed.energy {
128 next.energy = next.energy.saturating_add(energy);
129 }
130
131 next
132 }
133
134 fn local_time(&self) -> chrono::NaiveTime {
136 self.date_time.with_timezone(&self.local_timezone).time()
137 }
138
139 fn local_date(&self) -> chrono::NaiveDate {
141 self.date_time
142 .with_timezone(&self.local_timezone)
143 .date_naive()
144 }
145
146 fn local_weekday(&self) -> chrono::Weekday {
148 self.date_time.with_timezone(&self.local_timezone).weekday()
149 }
150}
151
152#[derive(Debug)]
155pub struct Report {
156 pub warnings: BTreeMap<String, Vec<WarningKind>>,
160
161 pub periods: Vec<PeriodReport>,
163
164 pub tariff_used: TariffOrigin,
166
167 pub tariff_reports: Vec<TariffReport>,
171
172 pub timezone: String,
174
175 pub billed_charging_time: Option<TimeDelta>,
178
179 pub billed_energy: Option<Kwh>,
181
182 pub billed_parking_time: Option<TimeDelta>,
184
185 pub total_charging_time: Option<TimeDelta>,
191
192 pub total_energy: Total<Kwh, Option<Kwh>>,
194
195 pub total_parking_time: Total<Option<TimeDelta>>,
197
198 pub total_time: Total<TimeDelta>,
200
201 pub total_cost: Total<Price, Option<Price>>,
204
205 pub total_energy_cost: Total<Option<Price>>,
207
208 pub total_fixed_cost: Total<Option<Price>>,
210
211 pub total_parking_cost: Total<Option<Price>>,
213
214 pub total_reservation_cost: Total<Option<Price>>,
216
217 pub total_time_cost: Total<Option<Price>>,
219}
220
221#[derive(Debug)]
223pub enum WarningKind {
224 Country(country::WarningKind),
226 Currency(currency::WarningKind),
227 DateTime(datetime::WarningKind),
228 Decode(json::decode::WarningKind),
229 Duration(duration::WarningKind),
230
231 FieldInvalidType {
233 expected_type: json::ValueKind,
235 },
236
237 FieldInvalidValue {
239 value: String,
241
242 message: Cow<'static, str>,
244 },
245
246 FieldRequired {
248 field_name: Cow<'static, str>,
249 },
250
251 Money(money::WarningKind),
252
253 NoPeriods,
255
256 Number(number::WarningKind),
257
258 PeriodsOutsideStartEndDateTime {
261 cdr_range: Range<DateTime<Utc>>,
262 period_range: PeriodRange,
263 },
264
265 String(string::WarningKind),
266 Weekday(weekday::WarningKind),
267}
268
269impl WarningKind {
270 fn field_invalid_value(
272 value: impl Into<String>,
273 message: impl Into<Cow<'static, str>>,
274 ) -> Self {
275 WarningKind::FieldInvalidValue {
276 value: value.into(),
277 message: message.into(),
278 }
279 }
280}
281
282impl fmt::Display for WarningKind {
283 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284 match self {
285 Self::String(warning_kind) => write!(f, "{warning_kind}"),
286 Self::Country(warning_kind) => write!(f, "{warning_kind}"),
287 Self::Currency(warning_kind) => write!(f, "{warning_kind}"),
288 Self::DateTime(warning_kind) => write!(f, "{warning_kind}"),
289 Self::Decode(warning_kind) => write!(f, "{warning_kind}"),
290 Self::Duration(warning_kind) => write!(f, "{warning_kind}"),
291 Self::FieldInvalidType { expected_type } => {
292 write!(f, "Field has invalid type. Expected type `{expected_type}`")
293 }
294 Self::FieldInvalidValue { value, message } => {
295 write!(f, "Field has invalid value `{value}`: {message}")
296 }
297 Self::FieldRequired { field_name } => {
298 write!(f, "Field is required: {field_name}")
299 }
300 Self::Money(warning_kind) => write!(f, "{warning_kind}"),
301 Self::NoPeriods => f.write_str("The CDR has no charging periods"),
302 Self::Number(warning_kind) => write!(f, "{warning_kind}"),
303 Self::PeriodsOutsideStartEndDateTime {
304 cdr_range,
305 period_range,
306 } => {
307 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)
308 }
309 Self::Weekday(warning_kind) => write!(f, "{warning_kind}"),
310 }
311 }
312}
313
314impl warning::Kind for WarningKind {
315 fn id(&self) -> Cow<'static, str> {
316 match self {
317 Self::String(kind) => kind.id(),
318 Self::Country(kind) => kind.id(),
319 Self::Currency(kind) => kind.id(),
320 Self::DateTime(kind) => kind.id(),
321 Self::Decode(kind) => kind.id(),
322 Self::Duration(kind) => kind.id(),
323 Self::FieldInvalidType { .. } => "field_invalid_type".into(),
324 Self::FieldInvalidValue { .. } => "field_invalid_value".into(),
325 Self::FieldRequired { .. } => "field_required".into(),
326 Self::Money(kind) => kind.id(),
327 Self::NoPeriods => "no_periods".into(),
328 Self::Number(kind) => kind.id(),
329 WarningKind::PeriodsOutsideStartEndDateTime { .. } => {
330 "periods_outside_start_end_date_time".into()
331 }
332 Self::Weekday(kind) => kind.id(),
333 }
334 }
335}
336
337impl From<country::WarningKind> for WarningKind {
338 fn from(warn_kind: country::WarningKind) -> Self {
339 Self::Country(warn_kind)
340 }
341}
342
343impl From<currency::WarningKind> for WarningKind {
344 fn from(warn_kind: currency::WarningKind) -> Self {
345 Self::Currency(warn_kind)
346 }
347}
348
349impl From<datetime::WarningKind> for WarningKind {
350 fn from(warn_kind: datetime::WarningKind) -> Self {
351 Self::DateTime(warn_kind)
352 }
353}
354
355impl From<duration::WarningKind> for WarningKind {
356 fn from(warn_kind: duration::WarningKind) -> Self {
357 Self::Duration(warn_kind)
358 }
359}
360
361impl From<json::decode::WarningKind> for WarningKind {
362 fn from(warn_kind: json::decode::WarningKind) -> Self {
363 Self::Decode(warn_kind)
364 }
365}
366
367impl From<money::WarningKind> for WarningKind {
368 fn from(warn_kind: money::WarningKind) -> Self {
369 Self::Money(warn_kind)
370 }
371}
372
373impl From<number::WarningKind> for WarningKind {
374 fn from(warn_kind: number::WarningKind) -> Self {
375 Self::Number(warn_kind)
376 }
377}
378
379impl From<string::WarningKind> for WarningKind {
380 fn from(warn_kind: string::WarningKind) -> Self {
381 Self::String(warn_kind)
382 }
383}
384
385impl From<weekday::WarningKind> for WarningKind {
386 fn from(warn_kind: weekday::WarningKind) -> Self {
387 Self::Weekday(warn_kind)
388 }
389}
390
391from_warning_set_to!(string::WarningKind => WarningKind);
392from_warning_set_to!(country::WarningKind => WarningKind);
393from_warning_set_to!(currency::WarningKind => WarningKind);
394from_warning_set_to!(datetime::WarningKind => WarningKind);
395from_warning_set_to!(duration::WarningKind => WarningKind);
396from_warning_set_to!(weekday::WarningKind => WarningKind);
397from_warning_set_to!(number::WarningKind => WarningKind);
398from_warning_set_to!(money::WarningKind => WarningKind);
399
400#[derive(Debug)]
402pub struct TariffReport {
403 pub origin: TariffOrigin,
405
406 pub warnings: BTreeMap<String, Vec<crate::tariff::WarningKind>>,
410}
411
412#[derive(Clone, Debug)]
414pub struct TariffOrigin {
415 pub index: usize,
417
418 pub id: String,
420}
421
422#[derive(Debug)]
424pub(crate) struct Period {
425 pub start_date_time: DateTime<Utc>,
427
428 pub consumed: Consumed,
430}
431
432#[derive(Debug)]
434pub struct Dimensions {
435 pub energy: Dimension<Kwh>,
437
438 pub flat: Dimension<()>,
440
441 pub duration_charging: Dimension<TimeDelta>,
443
444 pub duration_parking: Dimension<TimeDelta>,
446}
447
448impl Dimensions {
449 fn new(components: ComponentSet, consumed: &Consumed) -> Self {
450 Self {
451 energy: Dimension::new(components.energy, consumed.energy),
452 flat: Dimension::new(components.flat, Some(())),
453 duration_charging: Dimension::new(
454 components.duration_charging,
455 consumed.duration_charging,
456 ),
457 duration_parking: Dimension::new(
458 components.duration_parking,
459 consumed.duration_parking,
460 ),
461 }
462 }
463}
464
465#[derive(Debug)]
466pub struct Dimension<V> {
468 pub price: Option<Component>,
472
473 pub volume: Option<V>,
477
478 pub billed_volume: Option<V>,
486}
487
488impl<V> Dimension<V>
489where
490 V: Copy,
491{
492 fn new(price_component: Option<Component>, volume: Option<V>) -> Self {
493 Self {
494 price: price_component,
495 volume,
496 billed_volume: volume,
497 }
498 }
499}
500
501impl<V: Cost> Dimension<V> {
502 pub fn cost(&self) -> Option<Price> {
504 if let (Some(volume), Some(price_component)) = (&self.billed_volume, &self.price) {
505 let excl_vat = volume.cost(Money::from_decimal(price_component.price));
506
507 let incl_vat = match price_component.vat {
508 VatApplicable::Applicable(vat) => Some(excl_vat.apply_vat(vat)),
509 VatApplicable::Inapplicable => Some(excl_vat),
510 VatApplicable::Unknown => None,
511 };
512
513 Some(Price { excl_vat, incl_vat })
514 } else {
515 None
516 }
517 }
518}
519
520#[derive(Debug)]
525pub struct ComponentSet {
526 pub energy: Option<Component>,
528
529 pub flat: Option<Component>,
531
532 pub duration_charging: Option<Component>,
534
535 pub duration_parking: Option<Component>,
537}
538
539impl ComponentSet {
540 fn new() -> Self {
541 Self {
542 energy: None,
543 flat: None,
544 duration_charging: None,
545 duration_parking: None,
546 }
547 }
548
549 fn has_all_components(&self) -> bool {
551 let Self {
552 energy,
553 flat,
554 duration_charging,
555 duration_parking,
556 } = self;
557
558 flat.is_some()
559 && energy.is_some()
560 && duration_parking.is_some()
561 && duration_charging.is_some()
562 }
563}
564
565#[derive(Clone, Debug)]
570pub struct Component {
571 pub tariff_element_index: usize,
573
574 pub price: Decimal,
576
577 pub vat: VatApplicable,
580
581 pub step_size: u64,
589}
590
591impl Component {
592 fn new(component: &crate::tariff::v221::PriceComponent, tariff_element_index: usize) -> Self {
593 let crate::tariff::v221::PriceComponent {
594 price,
595 vat,
596 step_size,
597 dimension_type: _,
598 } = component;
599
600 Self {
601 tariff_element_index,
602 price: *price,
603 vat: *vat,
604 step_size: *step_size,
605 }
606 }
607}
608
609#[derive(Debug)]
623pub struct Total<TCdr, TCalc = TCdr> {
624 pub cdr: TCdr,
626
627 pub calculated: TCalc,
629}
630
631#[derive(Debug)]
633pub enum Error {
634 Cdr(warning::Set<WarningKind>),
636
637 Parse(ParseError),
639
640 DimensionShouldHaveVolume { dimension_name: &'static str },
642
643 DurationOverflow,
645
646 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
648
649 NoValidTariff,
659
660 Tariff(warning::Set<crate::tariff::WarningKind>),
663}
664
665impl From<InvalidPeriodIndex> for Error {
666 fn from(err: InvalidPeriodIndex) -> Self {
667 Self::Internal(err.into())
668 }
669}
670
671#[derive(Debug)]
672struct InvalidPeriodIndex(&'static str);
673
674impl std::error::Error for InvalidPeriodIndex {}
675
676impl fmt::Display for InvalidPeriodIndex {
677 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
678 write!(f, "Invalid index for period `{}`", self.0)
679 }
680}
681
682#[derive(Debug)]
684pub enum PeriodRange {
685 Many(Range<DateTime<Utc>>),
688
689 Single(DateTime<Utc>),
691}
692
693impl fmt::Display for PeriodRange {
694 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
695 match self {
696 PeriodRange::Many(Range { start, end }) => write!(f, "{start}-{end}"),
697 PeriodRange::Single(date_time) => write!(f, "{date_time}"),
698 }
699 }
700}
701
702impl From<ParseError> for Error {
703 fn from(err: ParseError) -> Self {
704 Error::Parse(err)
705 }
706}
707
708impl From<duration::Error> for Error {
709 fn from(err: duration::Error) -> Self {
710 match err {
711 duration::Error::Overflow => Self::DurationOverflow,
712 }
713 }
714}
715
716impl std::error::Error for Error {
717 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
718 if let Error::Internal(err) = self {
719 Some(&**err)
720 } else {
721 None
722 }
723 }
724}
725
726impl fmt::Display for Error {
727 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
728 match self {
729 Self::Cdr(warnings) => {
730 write!(f, "CDR warnings: {warnings:?}")
731 }
732 Self::Parse(err) => {
733 write!(f, "{err}")
734 }
735 Self::DimensionShouldHaveVolume { dimension_name } => {
736 write!(f, "Dimension `{dimension_name}` should have volume")
737 }
738 Self::DurationOverflow => {
739 f.write_str("A numeric overflow occurred while creating a duration")
740 }
741 Self::Internal(err) => {
742 write!(f, "Internal: {err}")
743 }
744 Self::NoValidTariff => {
745 f.write_str("No valid tariff has been found in the list of provided tariffs")
746 }
747 Self::Tariff(warnings) => {
748 write!(f, "Tariff warnings: {warnings:?}")
749 }
750 }
751 }
752}
753
754#[derive(Debug)]
755enum InternalError {
756 InvalidPeriodIndex {
757 index: usize,
758 field_name: &'static str,
759 },
760}
761
762impl std::error::Error for InternalError {}
763
764impl From<InternalError> for Error {
765 fn from(err: InternalError) -> Self {
766 Error::Internal(Box::new(err))
767 }
768}
769
770impl fmt::Display for InternalError {
771 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
772 match self {
773 InternalError::InvalidPeriodIndex { field_name, index } => {
774 write!(
775 f,
776 "Invalid period index for `{field_name}`; index: `{index}`"
777 )
778 }
779 }
780 }
781}
782
783#[derive(Debug)]
787pub enum TariffSource<'buf> {
788 UseCdr,
790
791 Override(Vec<crate::tariff::Versioned<'buf>>),
793}
794
795impl<'buf> TariffSource<'buf> {
796 pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
798 Self::Override(vec![tariff])
799 }
800}
801
802#[instrument(skip_all)]
803pub(super) fn cdr(
804 cdr_elem: &crate::cdr::Versioned<'_>,
805 tariff_source: TariffSource<'_>,
806 timezone: Tz,
807) -> Result<Report, Error> {
808 let cdr = parse_cdr(cdr_elem).map_err(Error::Cdr)?;
809
810 match tariff_source {
811 TariffSource::UseCdr => {
812 let (v221::cdr::WithTariffs { cdr, tariffs }, warnings) = cdr.into_parts();
813 debug!("Using tariffs from CDR");
814 let tariffs = tariffs
815 .iter()
816 .map(|elem| {
817 let tariff = crate::tariff::v211::Tariff::from_json(elem);
818 tariff.map_caveat(crate::tariff::v221::Tariff::from)
819 })
820 .collect::<Result<Vec<_>, _>>()
821 .map_err(Error::Tariff)?;
822
823 let cdr = cdr.into_caveat(warnings);
824
825 Ok(price_v221_cdr_with_tariffs(
826 cdr_elem, cdr, tariffs, timezone,
827 )?)
828 }
829 TariffSource::Override(tariffs) => {
830 let cdr = cdr.map(v221::cdr::WithTariffs::discard_tariffs);
831
832 debug!("Using override tariffs");
833 let tariffs = tariffs
834 .iter()
835 .map(parse_tariff)
836 .collect::<Result<Vec<_>, _>>()
837 .map_err(Error::Tariff)?;
838
839 Ok(price_v221_cdr_with_tariffs(
840 cdr_elem, cdr, tariffs, timezone,
841 )?)
842 }
843 }
844}
845
846fn parse_tariff<'caller: 'buf, 'buf>(
847 tariff: &'caller crate::tariff::Versioned<'buf>,
848) -> Verdict<crate::tariff::v221::Tariff<'buf>, crate::tariff::WarningKind> {
849 match tariff.version() {
850 Version::V211 => {
851 let tariff = crate::tariff::v211::Tariff::from_json(tariff.as_element());
852 tariff.map_caveat(crate::tariff::v221::Tariff::from)
853 }
854 Version::V221 => crate::tariff::v221::Tariff::from_json(tariff.as_element()),
855 }
856}
857fn price_v221_cdr_with_tariffs(
864 cdr_elem: &crate::cdr::Versioned<'_>,
865 cdr: Caveat<v221::Cdr, WarningKind>,
866 tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::WarningKind>>,
867 timezone: Tz,
868) -> Result<Report, Error> {
869 debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
870
871 let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
872 .into_iter()
873 .enumerate()
874 .map(|(index, tariff)| {
875 let (tariff, warnings) = tariff.into_parts();
876 let warnings = {
877 warnings
878 .into_group_by_elem(cdr_elem.as_element())
879 .map(|warning::IntoGroup { element, warnings }| {
880 (element.path().to_string(), warnings)
881 })
882 .collect()
883 };
884 (
885 TariffReport {
886 origin: TariffOrigin {
887 index,
888 id: tariff.id.to_string(),
889 },
890 warnings,
891 },
892 tariff,
893 )
894 })
895 .unzip();
896
897 debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
898
899 let tariffs_normalized = tariff::normalize_all(&tariffs);
900 let (tariff_index, tariff) = tariff::find_first_active(tariffs_normalized, cdr.start_date_time)
901 .ok_or(Error::NoValidTariff)?;
902
903 debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
904 debug!(%timezone, "Found timezone");
905
906 let cs_periods = v221::cdr::normalize_periods(&cdr, timezone)?;
907 let price_cdr_report = price_periods(&cs_periods, &tariff)?;
908
909 Ok(generate_report(
910 cdr_elem,
911 cdr,
912 timezone,
913 tariff_reports,
914 price_cdr_report,
915 TariffOrigin {
916 index: tariff_index,
917 id: tariff.id().to_string(),
918 },
919 ))
920}
921
922pub(crate) fn periods(
924 end_date_time: DateTime<Utc>,
925 timezone: Tz,
926 tariff: &crate::tariff::v221::Tariff<'_>,
927 periods: &mut [Period],
928) -> Result<PeriodsReport, Error> {
929 periods.sort_by_key(|p| p.start_date_time);
932 let mut out_periods = Vec::<PeriodNormalized>::new();
933
934 for (index, period) in periods.iter().enumerate() {
935 trace!(index, "processing\n{period:#?}");
936
937 let next_index = index + 1;
938
939 let end_date_time = if let Some(next_period) = periods.get(next_index) {
940 next_period.start_date_time
941 } else {
942 end_date_time
943 };
944
945 let next = if let Some(last) = out_periods.last() {
946 let start_snapshot = last.end_snapshot.clone();
947 let end_snapshot = start_snapshot.next(&period.consumed, end_date_time);
948
949 let period = PeriodNormalized {
950 consumed: period.consumed.clone(),
951 start_snapshot,
952 end_snapshot,
953 };
954 trace!("Adding new period based on the last added\n{period:#?}\n{last:#?}");
955 period
956 } else {
957 let start_snapshot = TotalsSnapshot::zero(period.start_date_time, timezone);
958 let end_snapshot = start_snapshot.next(&period.consumed, end_date_time);
959
960 let period = PeriodNormalized {
961 consumed: period.consumed.clone(),
962 start_snapshot,
963 end_snapshot,
964 };
965 trace!("Adding new period\n{period:#?}");
966 period
967 };
968
969 out_periods.push(next);
970 }
971
972 let tariff = Tariff::from_v221(tariff);
973 price_periods(&out_periods, &tariff)
974}
975
976fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> Result<PeriodsReport, Error> {
978 debug!(count = periods.len(), "Pricing CDR periods");
979
980 if tracing::enabled!(tracing::Level::TRACE) {
981 trace!("# CDR period list:");
982 for period in periods {
983 trace!("{period:#?}");
984 }
985 }
986
987 let period_totals = period_totals(periods, tariff);
988 let (billable, periods, totals) = period_totals.calculate_billed()?;
989 let total_costs = total_costs(&periods, tariff);
990
991 Ok(PeriodsReport {
992 billable,
993 periods,
994 totals,
995 total_costs,
996 })
997}
998
999pub(crate) struct PeriodsReport {
1001 pub billable: Billable,
1003
1004 pub periods: Vec<PeriodReport>,
1006
1007 pub totals: Totals,
1009
1010 pub total_costs: TotalCosts,
1012}
1013
1014#[derive(Debug)]
1020pub struct PeriodReport {
1021 pub start_date_time: DateTime<Utc>,
1023
1024 pub end_date_time: DateTime<Utc>,
1026
1027 pub dimensions: Dimensions,
1029}
1030
1031impl PeriodReport {
1032 fn new(period: &PeriodNormalized, dimensions: Dimensions) -> Self {
1033 Self {
1034 start_date_time: period.start_snapshot.date_time,
1035 end_date_time: period.end_snapshot.date_time,
1036 dimensions,
1037 }
1038 }
1039
1040 pub fn cost(&self) -> Option<Price> {
1042 [
1043 self.dimensions.duration_charging.cost(),
1044 self.dimensions.duration_parking.cost(),
1045 self.dimensions.flat.cost(),
1046 self.dimensions.energy.cost(),
1047 ]
1048 .into_iter()
1049 .fold(None, |accum, next| {
1050 if accum.is_none() && next.is_none() {
1051 None
1052 } else {
1053 Some(
1054 accum
1055 .unwrap_or_default()
1056 .saturating_add(next.unwrap_or_default()),
1057 )
1058 }
1059 })
1060 }
1061}
1062
1063struct PeriodTotals {
1065 periods: Vec<PeriodReport>,
1067
1068 step_size: StepSize,
1070
1071 totals: Totals,
1073}
1074
1075#[derive(Debug, Default)]
1077pub(crate) struct Totals {
1078 pub energy: Option<Kwh>,
1080
1081 pub duration_charging: Option<TimeDelta>,
1083
1084 pub duration_parking: Option<TimeDelta>,
1086}
1087
1088impl PeriodTotals {
1089 fn calculate_billed(self) -> Result<(Billable, Vec<PeriodReport>, Totals), Error> {
1093 let Self {
1094 mut periods,
1095 step_size,
1096 totals,
1097 } = self;
1098 let charging_time = totals
1099 .duration_charging
1100 .map(|dt| step_size.apply_time(&mut periods, dt))
1101 .transpose()?;
1102 let energy = totals
1103 .energy
1104 .map(|kwh| step_size.apply_energy(&mut periods, kwh))
1105 .transpose()?;
1106 let parking_time = totals
1107 .duration_parking
1108 .map(|dt| step_size.apply_parking_time(&mut periods, dt))
1109 .transpose()?;
1110 let billed = Billable {
1111 charging_time,
1112 energy,
1113 parking_time,
1114 };
1115 Ok((billed, periods, totals))
1116 }
1117}
1118
1119#[derive(Debug)]
1121pub(crate) struct Billable {
1122 charging_time: Option<TimeDelta>,
1124
1125 energy: Option<Kwh>,
1127
1128 parking_time: Option<TimeDelta>,
1130}
1131
1132fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1135 let mut has_flat_fee = false;
1136 let mut step_size = StepSize::new();
1137 let mut totals = Totals::default();
1138
1139 debug!(
1140 tariff_id = tariff.id(),
1141 period_count = periods.len(),
1142 "Accumulating dimension totals for each period"
1143 );
1144
1145 let periods = periods
1146 .iter()
1147 .enumerate()
1148 .map(|(index, period)| {
1149 let mut component_set = tariff.active_components(period);
1150 trace!(
1151 index,
1152 "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1153 );
1154
1155 if component_set.flat.is_some() {
1156 if has_flat_fee {
1157 component_set.flat = None;
1158 } else {
1159 has_flat_fee = true;
1160 }
1161 }
1162
1163 step_size.update(index, &component_set, period);
1164
1165 trace!(period_index = index, "Step size updated\n{step_size:#?}");
1166
1167 let dimensions = Dimensions::new(component_set, &period.consumed);
1168
1169 trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1170
1171 if let Some(dt) = dimensions.duration_charging.volume {
1172 let acc = totals.duration_charging.get_or_insert_default();
1173 *acc = acc.saturating_add(dt);
1174 }
1175
1176 if let Some(kwh) = dimensions.energy.volume {
1177 let acc = totals.energy.get_or_insert_default();
1178 *acc = acc.saturating_add(kwh);
1179 }
1180
1181 if let Some(dt) = dimensions.duration_parking.volume {
1182 let acc = totals.duration_parking.get_or_insert_default();
1183 *acc = acc.saturating_add(dt);
1184 }
1185
1186 trace!(period_index = index, ?totals, "Update totals");
1187
1188 PeriodReport::new(period, dimensions)
1189 })
1190 .collect::<Vec<_>>();
1191
1192 PeriodTotals {
1193 periods,
1194 step_size,
1195 totals,
1196 }
1197}
1198
1199#[derive(Debug, Default)]
1201pub(crate) struct TotalCosts {
1202 pub energy: Option<Price>,
1204
1205 pub fixed: Option<Price>,
1207
1208 pub duration_charging: Option<Price>,
1210
1211 pub duration_parking: Option<Price>,
1213}
1214
1215impl TotalCosts {
1216 pub(crate) fn total(&self) -> Option<Price> {
1220 let Self {
1221 energy,
1222 fixed,
1223 duration_charging,
1224 duration_parking,
1225 } = self;
1226 debug!(
1227 energy = %DisplayOption(*energy),
1228 fixed = %DisplayOption(*fixed),
1229 duration_charging = %DisplayOption(*duration_charging),
1230 duration_parking = %DisplayOption(*duration_parking),
1231 "Calculating total costs."
1232 );
1233 [energy, fixed, duration_charging, duration_parking]
1234 .into_iter()
1235 .fold(None, |accum: Option<Price>, next| match (accum, next) {
1236 (None, None) => None,
1237 _ => Some(
1238 accum
1239 .unwrap_or_default()
1240 .saturating_add(next.unwrap_or_default()),
1241 ),
1242 })
1243 }
1244}
1245
1246fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1248 let mut total_costs = TotalCosts::default();
1249
1250 debug!(
1251 tariff_id = tariff.id(),
1252 period_count = periods.len(),
1253 "Accumulating dimension costs for each period"
1254 );
1255 for (index, period) in periods.iter().enumerate() {
1256 let dimensions = &period.dimensions;
1257
1258 trace!(period_index = index, "Processing period");
1259
1260 let energy_cost = dimensions.energy.cost();
1261 let fixed_cost = dimensions.flat.cost();
1262 let duration_charging_cost = dimensions.duration_charging.cost();
1263 let duration_parking_cost = dimensions.duration_parking.cost();
1264
1265 trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1266 trace!(?total_costs.duration_charging, ?duration_charging_cost, "Energy cost");
1267 trace!(?total_costs.duration_parking, ?duration_parking_cost, "Energy cost");
1268 trace!(?total_costs.fixed, ?fixed_cost, "Energy cost");
1269
1270 total_costs.energy = match (total_costs.energy, energy_cost) {
1271 (None, None) => None,
1272 (total, period) => Some(
1273 total
1274 .unwrap_or_default()
1275 .saturating_add(period.unwrap_or_default()),
1276 ),
1277 };
1278
1279 total_costs.duration_charging =
1280 match (total_costs.duration_charging, duration_charging_cost) {
1281 (None, None) => None,
1282 (total, period) => Some(
1283 total
1284 .unwrap_or_default()
1285 .saturating_add(period.unwrap_or_default()),
1286 ),
1287 };
1288
1289 total_costs.duration_parking = match (total_costs.duration_parking, duration_parking_cost) {
1290 (None, None) => None,
1291 (total, period) => Some(
1292 total
1293 .unwrap_or_default()
1294 .saturating_add(period.unwrap_or_default()),
1295 ),
1296 };
1297
1298 total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1299 (None, None) => None,
1300 (total, period) => Some(
1301 total
1302 .unwrap_or_default()
1303 .saturating_add(period.unwrap_or_default()),
1304 ),
1305 };
1306
1307 trace!(period_index = index, ?total_costs, "Update totals");
1308 }
1309
1310 total_costs
1311}
1312
1313fn generate_report(
1314 cdr_elem: &crate::cdr::Versioned<'_>,
1315 cdr: Caveat<v221::Cdr, WarningKind>,
1316 timezone: Tz,
1317 tariff_reports: Vec<TariffReport>,
1318 price_periods_report: PeriodsReport,
1319 tariff_used: TariffOrigin,
1320) -> Report {
1321 let (cdr, warnings) = cdr.into_parts();
1322 let PeriodsReport {
1323 billable,
1324 periods,
1325 totals,
1326 total_costs,
1327 } = price_periods_report;
1328 trace!("Update billed totals {billable:#?}");
1329
1330 let total_cost = total_costs.total();
1331
1332 debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1333
1334 let total_time = {
1335 debug!(
1336 period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1337 period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1338 "Calculating `total_time`"
1339 );
1340
1341 periods
1342 .first()
1343 .zip(periods.last())
1344 .map(|(first, last)| {
1345 last.end_date_time
1346 .signed_duration_since(first.start_date_time)
1347 })
1348 .unwrap_or_default()
1349 };
1350 debug!(total_time = %Hms(total_time));
1351
1352 let warnings = {
1354 warnings
1355 .into_group_by_elem(cdr_elem.as_element())
1356 .map(|warning::IntoGroup { element, warnings }| (element.path().to_string(), warnings))
1357 .collect()
1358 };
1359
1360 let report = Report {
1361 warnings,
1362 periods,
1363 tariff_used,
1364 timezone: timezone.to_string(),
1365 billed_parking_time: billable.parking_time,
1366 billed_energy: billable.energy,
1367 billed_charging_time: billable.charging_time,
1368 tariff_reports,
1369 total_charging_time: totals.duration_charging,
1370 total_cost: Total {
1371 cdr: cdr.total_cost,
1372 calculated: total_cost,
1373 },
1374 total_time_cost: Total {
1375 cdr: cdr.total_time_cost,
1376 calculated: total_costs.duration_charging,
1377 },
1378 total_time: Total {
1379 cdr: cdr.total_time,
1380 calculated: total_time,
1381 },
1382 total_parking_cost: Total {
1383 cdr: cdr.total_parking_cost,
1384 calculated: total_costs.duration_parking,
1385 },
1386 total_parking_time: Total {
1387 cdr: cdr.total_parking_time,
1388 calculated: totals.duration_parking,
1389 },
1390 total_energy_cost: Total {
1391 cdr: cdr.total_energy_cost,
1392 calculated: total_costs.energy,
1393 },
1394 total_energy: Total {
1395 cdr: cdr.total_energy,
1396 calculated: totals.energy,
1397 },
1398 total_fixed_cost: Total {
1399 cdr: cdr.total_fixed_cost,
1400 calculated: total_costs.fixed,
1401 },
1402 total_reservation_cost: Total {
1403 cdr: cdr.total_reservation_cost,
1404 calculated: None,
1405 },
1406 };
1407
1408 trace!("{report:#?}");
1409
1410 report
1411}
1412
1413#[derive(Debug)]
1414struct StepSize {
1415 charging_time: Option<(usize, Component)>,
1416 parking_time: Option<(usize, Component)>,
1417 energy: Option<(usize, Component)>,
1418}
1419
1420fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1422 Decimal::from(delta.num_milliseconds())
1423 .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1424 .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1425}
1426
1427fn delta_from_seconds_dec(seconds: Decimal) -> Result<TimeDelta, duration::Error> {
1429 let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1430 let millis = i64::try_from(millis)?;
1431 let delta = TimeDelta::try_milliseconds(millis).ok_or(duration::Error::Overflow)?;
1432 Ok(delta)
1433}
1434
1435impl StepSize {
1436 fn new() -> Self {
1437 Self {
1438 charging_time: None,
1439 parking_time: None,
1440 energy: None,
1441 }
1442 }
1443
1444 fn update(&mut self, index: usize, components: &ComponentSet, period: &PeriodNormalized) {
1445 if period.consumed.energy.is_some() {
1446 if let Some(energy) = components.energy.clone() {
1447 self.energy = Some((index, energy));
1448 }
1449 }
1450
1451 if period.consumed.duration_charging.is_some() {
1452 if let Some(time) = components.duration_charging.clone() {
1453 self.charging_time = Some((index, time));
1454 }
1455 }
1456
1457 if period.consumed.duration_parking.is_some() {
1458 if let Some(parking) = components.duration_parking.clone() {
1459 self.parking_time = Some((index, parking));
1460 }
1461 }
1462 }
1463
1464 fn duration_step_size(
1465 total_volume: TimeDelta,
1466 period_billed_volume: &mut TimeDelta,
1467 step_size: u64,
1468 ) -> Result<TimeDelta, Error> {
1469 if step_size == 0 {
1470 return Ok(total_volume);
1471 }
1472
1473 let total_seconds = delta_as_seconds_dec(total_volume);
1474 let step_size = Decimal::from(step_size);
1475
1476 let total_billed_volume = delta_from_seconds_dec(
1477 total_seconds
1478 .checked_div(step_size)
1479 .ok_or(Error::DurationOverflow)?
1480 .ceil()
1481 .saturating_mul(step_size),
1482 )?;
1483
1484 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1485 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1486
1487 Ok(total_billed_volume)
1488 }
1489
1490 fn apply_time(
1491 &self,
1492 periods: &mut [PeriodReport],
1493 total: TimeDelta,
1494 ) -> Result<TimeDelta, Error> {
1495 let (Some((time_index, price)), None) = (&self.charging_time, &self.parking_time) else {
1496 return Ok(total);
1497 };
1498
1499 let Some(period) = periods.get_mut(*time_index) else {
1500 return Err(InternalError::InvalidPeriodIndex {
1501 index: *time_index,
1502 field_name: "apply_time",
1503 }
1504 .into());
1505 };
1506 let volume = period
1507 .dimensions
1508 .duration_charging
1509 .billed_volume
1510 .as_mut()
1511 .ok_or(Error::DimensionShouldHaveVolume {
1512 dimension_name: "time",
1513 })?;
1514
1515 Self::duration_step_size(total, volume, price.step_size)
1516 }
1517
1518 fn apply_parking_time(
1519 &self,
1520 periods: &mut [PeriodReport],
1521 total: TimeDelta,
1522 ) -> Result<TimeDelta, Error> {
1523 let Some((parking_index, price)) = &self.parking_time else {
1524 return Ok(total);
1525 };
1526
1527 let Some(period) = periods.get_mut(*parking_index) else {
1528 return Err(InternalError::InvalidPeriodIndex {
1529 index: *parking_index,
1530 field_name: "apply_parking_time",
1531 }
1532 .into());
1533 };
1534 let volume = period
1535 .dimensions
1536 .duration_parking
1537 .billed_volume
1538 .as_mut()
1539 .ok_or(Error::DimensionShouldHaveVolume {
1540 dimension_name: "parking_time",
1541 })?;
1542
1543 Self::duration_step_size(total, volume, price.step_size)
1544 }
1545
1546 fn apply_energy(&self, periods: &mut [PeriodReport], total_volume: Kwh) -> Result<Kwh, Error> {
1547 let Some((energy_index, price)) = &self.energy else {
1548 return Ok(total_volume);
1549 };
1550
1551 if price.step_size == 0 {
1552 return Ok(total_volume);
1553 }
1554
1555 let Some(period) = periods.get_mut(*energy_index) else {
1556 return Err(InternalError::InvalidPeriodIndex {
1557 index: *energy_index,
1558 field_name: "apply_energy",
1559 }
1560 .into());
1561 };
1562 let step_size = Decimal::from(price.step_size);
1563
1564 let period_billed_volume = period.dimensions.energy.billed_volume.as_mut().ok_or(
1565 Error::DimensionShouldHaveVolume {
1566 dimension_name: "energy",
1567 },
1568 )?;
1569
1570 let total_billed_volume = Kwh::from_watt_hours(
1571 total_volume
1572 .watt_hours()
1573 .checked_div(step_size)
1574 .ok_or(Error::DurationOverflow)?
1575 .ceil()
1576 .saturating_mul(step_size),
1577 );
1578
1579 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1580 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1581
1582 Ok(total_billed_volume)
1583 }
1584}
1585
1586fn parse_cdr<'caller: 'buf, 'buf>(
1587 cdr: &'caller crate::cdr::Versioned<'buf>,
1588) -> Verdict<v221::cdr::WithTariffs<'buf>, WarningKind> {
1589 match cdr.version() {
1590 Version::V211 => {
1591 let cdr = v211::cdr::WithTariffs::from_json(cdr.as_element())?;
1592 Ok(cdr.map(v221::cdr::WithTariffs::from))
1593 }
1594 Version::V221 => v221::cdr::WithTariffs::from_json(cdr.as_element()),
1595 }
1596}
1597
1598#[cfg(test)]
1599pub mod test {
1600 #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
1601 #![allow(clippy::panic, reason = "tests are allowed panic")]
1602
1603 use std::collections::{BTreeMap, BTreeSet};
1604
1605 use chrono::TimeDelta;
1606 use rust_decimal::Decimal;
1607 use serde::Deserialize;
1608 use tracing::debug;
1609
1610 use crate::{
1611 assert_approx_eq, cdr,
1612 duration::ToHoursDecimal,
1613 json, number,
1614 test::{ApproxEq, ExpectFile, Expectation},
1615 timezone,
1616 warning::{self, Kind as _},
1617 Kwh, Price,
1618 };
1619
1620 use super::{Error, Report, TariffReport, Total};
1621
1622 const PRECISION: u32 = 2;
1624
1625 #[test]
1626 const fn error_should_be_send_and_sync() {
1627 const fn f<T: Send + Sync>() {}
1628
1629 f::<Error>();
1630 }
1631
1632 pub trait UnwrapReport {
1633 #[track_caller]
1634 fn unwrap_report(self, cdr: &cdr::Versioned<'_>) -> Report;
1635 }
1636
1637 impl UnwrapReport for Result<Report, Error> {
1638 fn unwrap_report(self, cdr: &cdr::Versioned<'_>) -> Report {
1639 match self {
1640 Ok(v) => v,
1641 Err(err) => match err {
1642 Error::Cdr(warnings) => {
1643 panic!(
1644 "pricing CDR failed:\n{:?}",
1645 warning::SetWriter::new(cdr.as_element(), &warnings)
1646 );
1647 }
1648 Error::Tariff(warnings) => {
1649 panic!(
1650 "parsing tariff failed:\n{:?}",
1651 warning::SetWriter::new(cdr.as_element(), &warnings)
1652 );
1653 }
1654 _ => {
1655 panic!("pricing CDR failed: {err:?}");
1656 }
1657 },
1658 }
1659 }
1660 }
1661
1662 #[derive(Debug, Default)]
1664 pub(crate) struct HoursDecimal(Decimal);
1665
1666 impl ToHoursDecimal for HoursDecimal {
1667 fn to_hours_dec(&self) -> Decimal {
1668 self.0
1669 }
1670 }
1671
1672 fn decimal<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
1676 where
1677 D: serde::Deserializer<'de>,
1678 {
1679 use serde::Deserialize;
1680
1681 let mut d = <Decimal as Deserialize>::deserialize(deserializer)?;
1682 d.rescale(number::SCALE);
1683 Ok(d)
1684 }
1685
1686 impl<'de> Deserialize<'de> for HoursDecimal {
1687 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1688 where
1689 D: serde::Deserializer<'de>,
1690 {
1691 decimal(deserializer).map(Self)
1692 }
1693 }
1694
1695 #[derive(serde::Deserialize)]
1696 pub(crate) struct Expect {
1697 pub timezone_find: Option<timezone::test::FindOrInferExpect>,
1699
1700 pub tariff_parse: Option<ParseExpect>,
1702
1703 pub cdr_parse: Option<ParseExpect>,
1705
1706 pub cdr_price: Option<PriceExpect>,
1708 }
1709
1710 #[expect(
1713 clippy::struct_field_names,
1714 reason = "When deconstructed these fields will always be called *_expect. This avoids having to rename them in-place."
1715 )]
1716 pub(crate) struct ExpectFields {
1717 pub timezone_find_expect: ExpectFile<timezone::test::FindOrInferExpect>,
1719
1720 pub tariff_parse_expect: ExpectFile<ParseExpect>,
1722
1723 pub cdr_parse_expect: ExpectFile<ParseExpect>,
1725
1726 pub cdr_price_expect: ExpectFile<PriceExpect>,
1728 }
1729
1730 impl ExpectFile<Expect> {
1731 pub(crate) fn into_fields(self) -> ExpectFields {
1733 let ExpectFile {
1734 value,
1735 expect_file_name,
1736 } = self;
1737
1738 match value {
1739 Some(expect) => {
1740 let Expect {
1741 timezone_find,
1742 tariff_parse,
1743 cdr_parse,
1744 cdr_price,
1745 } = expect;
1746 ExpectFields {
1747 timezone_find_expect: ExpectFile::with_value(
1748 timezone_find,
1749 &expect_file_name,
1750 ),
1751 tariff_parse_expect: ExpectFile::with_value(
1752 tariff_parse,
1753 &expect_file_name,
1754 ),
1755 cdr_parse_expect: ExpectFile::with_value(cdr_parse, &expect_file_name),
1756 cdr_price_expect: ExpectFile::with_value(cdr_price, &expect_file_name),
1757 }
1758 }
1759 None => ExpectFields {
1760 timezone_find_expect: ExpectFile::only_file_name(&expect_file_name),
1761 tariff_parse_expect: ExpectFile::only_file_name(&expect_file_name),
1762 cdr_parse_expect: ExpectFile::only_file_name(&expect_file_name),
1763 cdr_price_expect: ExpectFile::only_file_name(&expect_file_name),
1764 },
1765 }
1766 }
1767 }
1768
1769 pub(crate) fn assert_parse_report(
1770 unexpected_fields: json::UnexpectedFields<'_>,
1771 expect: ExpectFile<ParseExpect>,
1772 ) {
1773 let ExpectFile {
1774 value,
1775 expect_file_name,
1776 } = expect;
1777 let unexpected_fields_expect = value
1778 .map(|exp| exp.unexpected_fields)
1779 .unwrap_or(Expectation::Absent);
1780
1781 if let Expectation::Present(expectation) = unexpected_fields_expect {
1782 let unexpected_fields_expect = expectation.expect_value();
1783
1784 for field in unexpected_fields {
1785 assert!(
1786 unexpected_fields_expect.contains(&field.to_string()),
1787 "The CDR has an unexpected field that's not expected in `{expect_file_name}`: `{field}`"
1788 );
1789 }
1790 } else {
1791 assert!(
1792 unexpected_fields.is_empty(),
1793 "The CDR has unexpected fields but the expect file doesn't `{expect_file_name}`; {unexpected_fields:#}",
1794 );
1795 }
1796 }
1797
1798 pub(crate) fn assert_price_report(report: Report, expect: ExpectFile<PriceExpect>) {
1799 let Report {
1800 warnings,
1801 mut tariff_reports,
1802 periods: _,
1803 tariff_used,
1804 timezone: _,
1805 billed_energy: _,
1806 billed_parking_time: _,
1807 billed_charging_time: _,
1808 total_charging_time: _,
1809 total_cost,
1810 total_fixed_cost,
1811 total_time,
1812 total_time_cost,
1813 total_energy,
1814 total_energy_cost,
1815 total_parking_time,
1816 total_parking_cost,
1817 total_reservation_cost,
1818 } = report;
1819
1820 let ExpectFile {
1821 value: expect,
1822 expect_file_name,
1823 } = expect;
1824
1825 let (
1828 warnings_expect,
1829 tariff_index_expect,
1830 tariff_id_expect,
1831 tariff_reports_expect,
1832 total_cost_expectation,
1833 total_fixed_cost_expectation,
1834 total_time_expectation,
1835 total_time_cost_expectation,
1836 total_energy_expectation,
1837 total_energy_cost_expectation,
1838 total_parking_time_expectation,
1839 total_parking_cost_expectation,
1840 total_reservation_cost_expectation,
1841 ) = expect
1842 .map(|exp| {
1843 let PriceExpect {
1844 warnings,
1845 tariff_index,
1846 tariff_id,
1847 tariff_reports,
1848 total_cost,
1849 total_fixed_cost,
1850 total_time,
1851 total_time_cost,
1852 total_energy,
1853 total_energy_cost,
1854 total_parking_time,
1855 total_parking_cost,
1856 total_reservation_cost,
1857 } = exp;
1858
1859 (
1860 warnings,
1861 tariff_index,
1862 tariff_id,
1863 tariff_reports,
1864 total_cost,
1865 total_fixed_cost,
1866 total_time,
1867 total_time_cost,
1868 total_energy,
1869 total_energy_cost,
1870 total_parking_time,
1871 total_parking_cost,
1872 total_reservation_cost,
1873 )
1874 })
1875 .unwrap_or((
1876 Expectation::Absent,
1877 Expectation::Absent,
1878 Expectation::Absent,
1879 Expectation::Absent,
1880 Expectation::Absent,
1881 Expectation::Absent,
1882 Expectation::Absent,
1883 Expectation::Absent,
1884 Expectation::Absent,
1885 Expectation::Absent,
1886 Expectation::Absent,
1887 Expectation::Absent,
1888 Expectation::Absent,
1889 ));
1890
1891 if let Expectation::Present(expectation) = warnings_expect {
1892 let warnings_expect = expectation.expect_value();
1893
1894 debug!("{warnings_expect:?}");
1895
1896 for (elem_path, warnings) in warnings {
1897 let Some(warnings_expect) = warnings_expect.get(&*elem_path) else {
1898 let warning_ids = warnings
1899 .iter()
1900 .map(|k| format!(" \"{}\",", k.id()))
1901 .collect::<Vec<_>>()
1902 .join("\n");
1903
1904 panic!("No warnings expected `{expect_file_name}` for `Element` at `{elem_path}` but {} warnings were reported:\n[\n{}\n]", warnings.len(), warning_ids);
1905 };
1906
1907 let warnings_expect = warnings_expect
1908 .iter()
1909 .map(|s| &**s)
1910 .collect::<BTreeSet<_>>();
1911
1912 for warning_kind in warnings {
1913 let id = warning_kind.id();
1914 assert!(
1915 warnings_expect.contains(&*id),
1916 "Unexpected warning `{id}` for `Element` at `{elem_path}`"
1917 );
1918 }
1919 }
1920 } else {
1921 assert!(warnings.is_empty(), "The CDR has warnings; {warnings:?}",);
1922 }
1923
1924 if let Expectation::Present(expectation) = tariff_reports_expect {
1925 let tariff_reports_expect: BTreeMap<_, _> = expectation
1926 .expect_value()
1927 .into_iter()
1928 .map(|TariffReportExpect { id, warnings }| (id, warnings))
1929 .collect();
1930
1931 for report in &mut tariff_reports {
1932 let TariffReport { origin, warnings } = report;
1933 let id = &origin.id;
1934 let Some(warnings_expect) = tariff_reports_expect.get(id) else {
1935 panic!("A tariff with {id} is not expected `{expect_file_name}`");
1936 };
1937
1938 debug!("{warnings_expect:?}");
1939
1940 for (elem_path, warnings) in warnings {
1941 let Some(warnings_expect) = warnings_expect.get(elem_path) else {
1942 let warning_ids = warnings
1943 .iter()
1944 .map(|k| format!(" \"{}\",", k.id()))
1945 .collect::<Vec<_>>()
1946 .join("\n");
1947
1948 panic!("No warnings expected for `Element` at `{elem_path}` but {} warnings were reported:\n[\n{}\n]", warnings.len(), warning_ids);
1949 };
1950
1951 let warnings_expect = warnings_expect
1952 .iter()
1953 .map(|s| &**s)
1954 .collect::<BTreeSet<_>>();
1955
1956 for warning_kind in warnings {
1957 let id = warning_kind.id();
1958 assert!(
1959 warnings_expect.contains(&*id),
1960 "Unexpected warning `{id}` for `Element` at `{elem_path}`"
1961 );
1962 }
1963 }
1964 }
1965 } else {
1966 for report in &tariff_reports {
1967 let TariffReport { origin, warnings } = report;
1968
1969 let id = &origin.id;
1970
1971 assert!(
1972 warnings.is_empty(),
1973 "The tariff with id `{id}` has warnings.\n {warnings:?}"
1974 );
1975 }
1976 }
1977
1978 if let Expectation::Present(expectation) = tariff_id_expect {
1979 assert_eq!(tariff_used.id, expectation.expect_value());
1980 }
1981
1982 if let Expectation::Present(expectation) = tariff_index_expect {
1983 assert_eq!(tariff_used.index, expectation.expect_value());
1984 }
1985
1986 total_cost_expectation.expect_price("total_cost", &total_cost);
1987 total_fixed_cost_expectation.expect_opt_price("total_fixed_cost", &total_fixed_cost);
1988 total_time_expectation.expect_duration("total_time", &total_time);
1989 total_time_cost_expectation.expect_opt_price("total_time_cost", &total_time_cost);
1990 total_energy_expectation.expect_opt_kwh("total_energy", &total_energy);
1991 total_energy_cost_expectation.expect_opt_price("total_energy_cost", &total_energy_cost);
1992 total_parking_time_expectation
1993 .expect_opt_duration("total_parking_time", &total_parking_time);
1994 total_parking_cost_expectation.expect_opt_price("total_parking_cost", &total_parking_cost);
1995 total_reservation_cost_expectation
1996 .expect_opt_price("total_reservation_cost", &total_reservation_cost);
1997 }
1998
1999 #[derive(serde::Deserialize)]
2001 pub struct ParseExpect {
2002 #[serde(default)]
2003 unexpected_fields: Expectation<Vec<String>>,
2004 }
2005
2006 #[derive(serde::Deserialize)]
2008 pub struct PriceExpect {
2009 #[serde(default)]
2013 warnings: Expectation<BTreeMap<String, Vec<String>>>,
2014
2015 #[serde(default)]
2017 tariff_index: Expectation<usize>,
2018
2019 #[serde(default)]
2021 tariff_id: Expectation<String>,
2022
2023 #[serde(default)]
2027 tariff_reports: Expectation<Vec<TariffReportExpect>>,
2028
2029 #[serde(default)]
2031 total_cost: Expectation<Price>,
2032
2033 #[serde(default)]
2035 total_fixed_cost: Expectation<Price>,
2036
2037 #[serde(default)]
2039 total_time: Expectation<HoursDecimal>,
2040
2041 #[serde(default)]
2043 total_time_cost: Expectation<Price>,
2044
2045 #[serde(default)]
2047 total_energy: Expectation<Kwh>,
2048
2049 #[serde(default)]
2051 total_energy_cost: Expectation<Price>,
2052
2053 #[serde(default)]
2055 total_parking_time: Expectation<HoursDecimal>,
2056
2057 #[serde(default)]
2059 total_parking_cost: Expectation<Price>,
2060
2061 #[serde(default)]
2063 total_reservation_cost: Expectation<Price>,
2064 }
2065
2066 #[derive(Debug, Deserialize)]
2067 struct TariffReportExpect {
2068 id: String,
2070
2071 #[serde(default)]
2075 warnings: BTreeMap<String, Vec<String>>,
2076 }
2077
2078 impl Expectation<Price> {
2079 #[track_caller]
2080 fn expect_opt_price(self, field_name: &str, total: &Total<Option<Price>>) {
2081 if let Expectation::Present(expect_value) = self {
2082 match (expect_value.into_option(), total.calculated) {
2083 (Some(a), Some(b)) => assert!(
2084 a.approx_eq(&b),
2085 "Expected `{a}` but `{b}` was calculated for `{field_name}`"
2086 ),
2087 (Some(a), None) => {
2088 panic!("Expected `{a}`, but no price was calculated for `{field_name}`")
2089 }
2090 (None, Some(b)) => {
2091 panic!("Expected no value, but `{b}` was calculated for `{field_name}`")
2092 }
2093 (None, None) => (),
2094 }
2095 } else {
2096 match (total.cdr, total.calculated) {
2097 (None, None) => (),
2098 (None, Some(calculated)) => {
2099 assert!(calculated.is_zero(), "The CDR field `{field_name}` doesn't have a value but a value was calculated; calculated: {calculated}");
2100 }
2101 (Some(cdr), None) => {
2102 assert!(
2103 cdr.is_zero(),
2104 "The CDR field `{field_name}` has a value but the calculated value is none; cdr: {cdr}"
2105 );
2106 }
2107 (Some(cdr), Some(calculated)) => {
2108 assert!(
2109 cdr.approx_eq(&calculated),
2110 "Comparing `{field_name}` field with CDR"
2111 );
2112 }
2113 }
2114 }
2115 }
2116
2117 #[track_caller]
2118 fn expect_price(self, field_name: &str, total: &Total<Price, Option<Price>>) {
2119 if let Expectation::Present(expect_value) = self {
2120 match (expect_value.into_option(), total.calculated) {
2121 (Some(a), Some(b)) => assert!(
2122 a.approx_eq(&b),
2123 "Expected `{a}` but `{b}` was calculated for `{field_name}`"
2124 ),
2125 (Some(a), None) => {
2126 panic!("Expected `{a}`, but no price was calculated for `{field_name}`")
2127 }
2128 (None, Some(b)) => {
2129 panic!("Expected no value, but `{b}` was calculated for `{field_name}`")
2130 }
2131 (None, None) => (),
2132 }
2133 } else if let Some(calculated) = total.calculated {
2134 assert!(
2135 total.cdr.approx_eq(&calculated),
2136 "CDR contains `{}` but `{}` was calculated for `{field_name}`",
2137 total.cdr,
2138 calculated
2139 );
2140 } else {
2141 assert!(
2142 total.cdr.is_zero(),
2143 "The CDR field `{field_name}` has a value but the calculated value is none; cdr: {:?}",
2144 total.cdr
2145 );
2146 }
2147 }
2148 }
2149
2150 impl Expectation<HoursDecimal> {
2151 #[track_caller]
2152 fn expect_duration(self, field_name: &str, total: &Total<TimeDelta>) {
2153 if let Expectation::Present(expect_value) = self {
2154 assert_approx_eq!(
2155 expect_value.expect_value().to_hours_dec(),
2156 total.calculated.to_hours_dec(),
2157 "Comparing `{field_name}` field with expectation"
2158 );
2159 } else {
2160 assert_approx_eq!(
2161 total.cdr.to_hours_dec(),
2162 total.calculated.to_hours_dec(),
2163 "Comparing `{field_name}` field with CDR"
2164 );
2165 }
2166 }
2167
2168 #[track_caller]
2169 fn expect_opt_duration(
2170 self,
2171 field_name: &str,
2172 total: &Total<Option<TimeDelta>, Option<TimeDelta>>,
2173 ) {
2174 if let Expectation::Present(expect_value) = self {
2175 assert_approx_eq!(
2176 expect_value
2177 .into_option()
2178 .unwrap_or_default()
2179 .to_hours_dec(),
2180 &total
2181 .calculated
2182 .as_ref()
2183 .map(ToHoursDecimal::to_hours_dec)
2184 .unwrap_or_default(),
2185 "Comparing `{field_name}` field with expectation"
2186 );
2187 } else {
2188 assert_approx_eq!(
2189 total.cdr.unwrap_or_default().to_hours_dec(),
2190 total.calculated.unwrap_or_default().to_hours_dec(),
2191 "Comparing `{field_name}` field with CDR"
2192 );
2193 }
2194 }
2195 }
2196
2197 impl Expectation<Kwh> {
2198 #[track_caller]
2199 fn expect_opt_kwh(self, field_name: &str, total: &Total<Kwh, Option<Kwh>>) {
2200 if let Expectation::Present(expect_value) = self {
2201 assert_eq!(
2202 expect_value
2203 .into_option()
2204 .map(|kwh| kwh.round_dp(PRECISION)),
2205 total
2206 .calculated
2207 .map(|kwh| kwh.rescale().round_dp(PRECISION)),
2208 "Comparing `{field_name}` field with expectation"
2209 );
2210 } else {
2211 assert_eq!(
2212 total.cdr.round_dp(PRECISION),
2213 total
2214 .calculated
2215 .map(|kwh| kwh.rescale().round_dp(PRECISION))
2216 .unwrap_or_default(),
2217 "Comparing `{field_name}` field with CDR"
2218 );
2219 }
2220 }
2221 }
2222}
2223
2224#[cfg(test)]
2225mod test_periods {
2226 #![allow(clippy::as_conversions, reason = "tests are allowed to panic")]
2227 #![allow(clippy::panic, reason = "tests are allowed panic")]
2228
2229 use chrono::Utc;
2230 use chrono_tz::Tz;
2231 use rust_decimal::Decimal;
2232 use rust_decimal_macros::dec;
2233
2234 use crate::{
2235 assert_approx_eq, cdr,
2236 price::{self, parse_tariff, test::UnwrapReport},
2237 tariff, Kwh, Version,
2238 };
2239
2240 use super::{Consumed, Period, TariffSource};
2241
2242 #[test]
2243 fn should_price_periods_from_time_and_parking_time_cdr_and_tariff() {
2244 const VERSION: Version = Version::V211;
2245 const CDR_JSON: &str = include_str!(
2246 "../test_data/v211/real_world/time_and_parking_time_separate_tariff/cdr.json"
2247 );
2248 const TARIFF_JSON: &str = include_str!(
2249 "../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json"
2250 );
2251 const PERIOD_DURATION: chrono::TimeDelta = chrono::TimeDelta::minutes(15);
2253
2254 fn charging(start_date_time: &str, energy: Vec<Decimal>) -> Vec<Period> {
2259 let start: chrono::DateTime<Utc> = start_date_time.parse().unwrap();
2260
2261 energy
2262 .into_iter()
2263 .enumerate()
2264 .map(|(i, kwh)| {
2265 let i = i32::try_from(i).unwrap();
2266 let start_date_time = start + (PERIOD_DURATION * i);
2267
2268 Period {
2269 start_date_time,
2270 consumed: Consumed {
2271 duration_charging: Some(PERIOD_DURATION),
2272 energy: Some(kwh.into()),
2273 ..Default::default()
2274 },
2275 }
2276 })
2277 .collect()
2278 }
2279
2280 fn parking(start_date_time: &str, period_count: usize) -> Vec<Period> {
2285 let period_energy = Kwh::from(0);
2287 let start: chrono::DateTime<Utc> = start_date_time.parse().unwrap();
2288
2289 let period_count = i32::try_from(period_count).unwrap();
2290 let mut periods: Vec<Period> = (0..period_count - 1)
2292 .map(|i| {
2293 let start_date_time = start + (PERIOD_DURATION * i);
2294
2295 Period {
2296 start_date_time,
2297 consumed: Consumed {
2298 duration_parking: Some(PERIOD_DURATION),
2299 energy: Some(period_energy),
2300 ..Default::default()
2301 },
2302 }
2303 })
2304 .collect();
2305
2306 let start_date_time = start + (PERIOD_DURATION * (period_count - 1));
2307
2308 periods.push(Period {
2310 start_date_time,
2311 consumed: Consumed {
2312 duration_parking: Some(chrono::TimeDelta::seconds(644)),
2313 energy: Some(period_energy),
2314 ..Default::default()
2315 },
2316 });
2317
2318 periods
2319 }
2320
2321 let report = cdr::parse_with_version(CDR_JSON, VERSION).unwrap();
2322 let cdr::ParseReport {
2323 cdr,
2324 unexpected_fields,
2325 } = report;
2326
2327 assert!(unexpected_fields.is_empty());
2328 let tariff::ParseReport {
2329 tariff,
2330 unexpected_fields,
2331 } = tariff::parse_with_version(TARIFF_JSON, VERSION).unwrap();
2332 assert!(unexpected_fields.is_empty());
2333
2334 let report = cdr::price(
2336 &cdr,
2337 TariffSource::Override(vec![tariff.clone()]),
2338 Tz::Europe__Amsterdam,
2339 )
2340 .unwrap_report(&cdr);
2341
2342 let price::Report {
2343 warnings,
2344 periods,
2346 tariff_used: _,
2348 tariff_reports: _,
2349 timezone: _,
2350 billed_energy,
2351 billed_parking_time,
2352 billed_charging_time,
2353 total_charging_time,
2354 total_energy,
2355 total_parking_time,
2356 total_time: _,
2358 total_cost,
2359 total_energy_cost,
2360 total_fixed_cost,
2361 total_parking_cost,
2362 total_reservation_cost: _,
2364 total_time_cost,
2365 } = report;
2366
2367 assert!(warnings.is_empty());
2368
2369 let mut cdr_periods = charging(
2370 "2025-04-09T16:12:54.000Z",
2371 vec![
2372 dec!(2.75),
2373 dec!(2.77),
2374 dec!(1.88),
2375 dec!(2.1),
2376 dec!(2.09),
2377 dec!(2.11),
2378 dec!(2.09),
2379 dec!(2.09),
2380 dec!(2.09),
2381 dec!(2.09),
2382 dec!(2.09),
2383 dec!(2.09),
2384 dec!(2.09),
2385 dec!(2.11),
2386 dec!(2.13),
2387 dec!(2.09),
2388 dec!(2.11),
2389 dec!(2.12),
2390 dec!(2.13),
2391 dec!(2.1),
2392 dec!(2.0),
2393 dec!(0.69),
2394 dec!(0.11),
2395 ],
2396 );
2397 let mut periods_parking = parking("2025-04-09T21:57:55.000Z", 47);
2398
2399 cdr_periods.append(&mut periods_parking);
2400 cdr_periods.sort_by_key(|p| p.start_date_time);
2401
2402 assert_eq!(
2403 cdr_periods.len(),
2404 periods.len(),
2405 "The amount of `price::Report` periods should equal the periods given to the `price::periods` fn"
2406 );
2407 assert_eq!(
2408 periods.len(),
2409 70,
2410 "The `time_and_parking/cdr.json` has 70 `charging_periods`"
2411 );
2412
2413 assert!(periods
2414 .iter()
2415 .map(|p| p.start_date_time)
2416 .collect::<Vec<_>>()
2417 .is_sorted());
2418
2419 let (tariff, warnings) = parse_tariff(&tariff).unwrap().into_parts();
2420 assert!(warnings.is_empty());
2421
2422 let periods_report = price::periods(
2423 "2025-04-10T09:38:38.000Z".parse().unwrap(),
2424 chrono_tz::Europe::Amsterdam,
2425 &tariff,
2426 &mut cdr_periods,
2427 )
2428 .unwrap();
2429
2430 let price::PeriodsReport {
2431 billable,
2432 periods,
2433 totals,
2434 total_costs,
2435 } = periods_report;
2436
2437 assert_eq!(
2438 cdr_periods.len(),
2439 periods.len(),
2440 "The amount of `price::Report` periods should equal the periods given to the `price::periods` fn"
2441 );
2442 assert_eq!(
2443 periods.len(),
2444 70,
2445 "The `time_and_parking/cdr.json` has 70 `charging_periods`"
2446 );
2447
2448 assert_approx_eq!(billable.charging_time, billed_charging_time);
2449 assert_approx_eq!(billable.energy, billed_energy);
2450 assert_approx_eq!(billable.parking_time, billed_parking_time,);
2451
2452 assert_approx_eq!(totals.duration_charging, total_charging_time);
2453 assert_approx_eq!(totals.energy, total_energy.calculated);
2454 assert_approx_eq!(totals.duration_parking, total_parking_time.calculated);
2455
2456 assert_approx_eq!(total_costs.duration_charging, total_time_cost.calculated,);
2457 assert_approx_eq!(total_costs.energy, total_energy_cost.calculated,);
2458 assert_approx_eq!(total_costs.fixed, total_fixed_cost.calculated);
2459 assert_approx_eq!(total_costs.duration_parking, total_parking_cost.calculated);
2460 assert_approx_eq!(total_costs.total(), total_cost.calculated);
2461 }
2462}
2463
2464#[cfg(test)]
2465mod test_validate_cdr {
2466 use assert_matches::assert_matches;
2467
2468 use crate::{
2469 cdr,
2470 json::FromJson,
2471 price::{self, v221, WarningKind},
2472 test::{self, datetime_from_str},
2473 };
2474
2475 #[test]
2476 fn should_pass_parse_validation() {
2477 test::setup();
2478 let json = cdr_json("2022-01-13T16:00:00Z", "2022-01-13T19:12:00Z");
2479 let cdr::ParseReport {
2480 cdr,
2481 unexpected_fields,
2482 } = cdr::parse_with_version(&json, crate::Version::V221).unwrap();
2483 assert!(unexpected_fields.is_empty());
2484 let (_cdr, warnings) = v221::Cdr::from_json(cdr.as_element()).unwrap().into_parts();
2485 assert!(warnings.is_empty());
2486 }
2487
2488 #[test]
2489 fn should_fail_validation_start_end_range_doesnt_overlap_with_periods() {
2490 test::setup();
2491
2492 let json = cdr_json("2022-02-13T16:00:00Z", "2022-02-13T19:12:00Z");
2493 let cdr::ParseReport {
2494 cdr,
2495 unexpected_fields,
2496 } = cdr::parse_with_version(&json, crate::Version::V221).unwrap();
2497 assert!(unexpected_fields.is_empty());
2498 let (_cdr, warnings) = v221::Cdr::from_json(cdr.as_element()).unwrap().into_parts();
2499 let warnings = warnings.into_kind_vec();
2500 let [warning] = warnings.try_into().unwrap();
2501 let (cdr_range, period_range) = assert_matches!(warning, WarningKind::PeriodsOutsideStartEndDateTime { cdr_range, period_range } => (cdr_range, period_range));
2502
2503 {
2504 assert_eq!(cdr_range.start, datetime_from_str("2022-02-13T16:00:00Z"));
2505 assert_eq!(cdr_range.end, datetime_from_str("2022-02-13T19:12:00Z"));
2506 }
2507 {
2508 let period_range =
2509 assert_matches!(period_range, price::PeriodRange::Many(range) => range);
2510
2511 assert_eq!(
2512 period_range.start,
2513 datetime_from_str("2022-01-13T16:00:00Z")
2514 );
2515 assert_eq!(period_range.end, datetime_from_str("2022-01-13T18:30:00Z"));
2516 }
2517 }
2518
2519 fn cdr_json(start_date_time: &str, end_date_time: &str) -> String {
2520 let value = serde_json::json!({
2521 "start_date_time": start_date_time,
2522 "end_date_time": end_date_time,
2523 "currency": "EUR",
2524 "tariffs": [],
2525 "cdr_location": {
2526 "country": "NLD"
2527 },
2528 "charging_periods": [
2529 {
2530 "start_date_time": "2022-01-13T16:00:00Z",
2531 "dimensions": [
2532 {
2533 "type": "TIME",
2534 "volume": 2.5
2535 }
2536 ]
2537 },
2538 {
2539 "start_date_time": "2022-01-13T18:30:00Z",
2540 "dimensions": [
2541 {
2542 "type": "PARKING_TIME",
2543 "volume": 0.7
2544 }
2545 ]
2546 }
2547 ],
2548 "total_cost": {
2549 "excl_vat": 11.25,
2550 "incl_vat": 12.75
2551 },
2552 "total_time_cost": {
2553 "excl_vat": 7.5,
2554 "incl_vat": 8.25
2555 },
2556 "total_parking_time": 0.7,
2557 "total_parking_cost": {
2558 "excl_vat": 3.75,
2559 "incl_vat": 4.5
2560 },
2561 "total_time": 3.2,
2562 "total_energy": 0,
2563 "last_updated": "2022-01-13T00:00:00Z"
2564 });
2565
2566 value.to_string()
2567 }
2568}
2569
2570#[cfg(test)]
2571mod test_real_world_v211 {
2572 use std::path::Path;
2573
2574 use crate::{
2575 cdr,
2576 price::{
2577 self,
2578 test::{Expect, ExpectFields, UnwrapReport},
2579 },
2580 tariff, test, timezone, Version,
2581 };
2582
2583 #[test_each::file(
2584 glob = "ocpi-tariffs/test_data/v211/real_world/*/cdr*.json",
2585 name(segments = 2)
2586 )]
2587 fn test_price_cdr(cdr_json: &str, path: &Path) {
2588 const VERSION: Version = Version::V211;
2589
2590 test::setup();
2591
2592 let expect_json = test::read_expect_json(path, "price");
2593 let expect = test::parse_expect_json::<Expect>(expect_json.as_deref());
2594
2595 let ExpectFields {
2596 timezone_find_expect,
2597 tariff_parse_expect,
2598 cdr_parse_expect,
2599 cdr_price_expect,
2600 } = expect.into_fields();
2601
2602 let tariff_json = std::fs::read_to_string(path.parent().unwrap().join("tariff.json")).ok();
2603 let tariff = tariff_json
2604 .as_deref()
2605 .map(|json| tariff::parse_with_version(json, VERSION))
2606 .transpose()
2607 .unwrap();
2608
2609 let tariff = if let Some(parse_report) = tariff {
2610 let tariff::ParseReport {
2611 tariff,
2612 unexpected_fields,
2613 } = parse_report;
2614 price::test::assert_parse_report(unexpected_fields, tariff_parse_expect);
2615 price::TariffSource::Override(vec![tariff])
2616 } else {
2617 assert!(tariff_parse_expect.value.is_none(), "There is no separate tariff to parse so there is no need to define a `tariff_parse` expectation");
2618 price::TariffSource::UseCdr
2619 };
2620
2621 let report = cdr::parse_with_version(cdr_json, VERSION).unwrap();
2622 let cdr::ParseReport {
2623 cdr,
2624 unexpected_fields,
2625 } = report;
2626 price::test::assert_parse_report(unexpected_fields, cdr_parse_expect);
2627
2628 let (timezone_source, warnings) = timezone::find_or_infer(&cdr).into_parts();
2629 let timezone_source = timezone_source.unwrap();
2630
2631 timezone::test::assert_find_or_infer_outcome(
2632 &cdr,
2633 timezone_source,
2634 timezone_find_expect,
2635 &warnings,
2636 );
2637
2638 let report = cdr::price(&cdr, tariff, timezone_source.into_timezone()).unwrap_report(&cdr);
2639 price::test::assert_price_report(report, cdr_price_expect);
2640 }
2641}
2642
2643#[cfg(test)]
2644mod test_real_world_v221 {
2645 use std::path::Path;
2646
2647 use crate::{
2648 cdr,
2649 price::{
2650 self,
2651 test::{ExpectFields, UnwrapReport},
2652 },
2653 tariff, test, timezone, Version,
2654 };
2655
2656 #[test_each::file(
2657 glob = "ocpi-tariffs/test_data/v221/real_world/*/cdr*.json",
2658 name(segments = 2)
2659 )]
2660 fn test_price_cdr(cdr_json: &str, path: &Path) {
2661 const VERSION: Version = Version::V221;
2662
2663 test::setup();
2664
2665 let expect_json = test::read_expect_json(path, "price");
2666 let expect = test::parse_expect_json(expect_json.as_deref());
2667 let ExpectFields {
2668 timezone_find_expect,
2669 tariff_parse_expect,
2670 cdr_parse_expect,
2671 cdr_price_expect,
2672 } = expect.into_fields();
2673
2674 let tariff_json = std::fs::read_to_string(path.parent().unwrap().join("tariff.json")).ok();
2675 let tariff = tariff_json
2676 .as_deref()
2677 .map(|json| tariff::parse_with_version(json, VERSION))
2678 .transpose()
2679 .unwrap();
2680 let tariff = tariff
2681 .map(|report| {
2682 let tariff::ParseReport {
2683 tariff,
2684 unexpected_fields,
2685 } = report;
2686 price::test::assert_parse_report(unexpected_fields, tariff_parse_expect);
2687 price::TariffSource::Override(vec![tariff])
2688 })
2689 .unwrap_or(price::TariffSource::UseCdr);
2690
2691 let report = cdr::parse_with_version(cdr_json, VERSION).unwrap();
2692 let cdr::ParseReport {
2693 cdr,
2694 unexpected_fields,
2695 } = report;
2696 price::test::assert_parse_report(unexpected_fields, cdr_parse_expect);
2697
2698 let (timezone_source, warnings) = timezone::find_or_infer(&cdr).into_parts();
2699 let timezone_source = timezone_source.unwrap();
2700
2701 timezone::test::assert_find_or_infer_outcome(
2702 &cdr,
2703 timezone_source,
2704 timezone_find_expect,
2705 &warnings,
2706 );
2707
2708 let report = cdr::price(&cdr, tariff, timezone_source.into_timezone()).unwrap_report(&cdr);
2722 price::test::assert_price_report(report, cdr_price_expect);
2723 }
2724}