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