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