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