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