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