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