1mod restriction;
2mod session;
3mod tariff;
4mod v211;
5mod v221;
6
7use std::{borrow::Cow, fmt, ops::Range};
8
9use chrono_tz::Tz;
10use serde::Serialize;
11use tracing::{debug, instrument, trace};
12
13pub(crate) use tariff::Tariff;
14pub use v221::tariff::CompatibilityVat;
15
16use crate::{
17 de::obj_from_json_str, duration, warning, DateTime, HoursDecimal, Kwh, Number, ParseError,
18 Price, TariffId, UnexpectedFields, Version,
19};
20
21#[derive(Debug, Serialize)]
24pub struct Report {
25 pub warnings: Vec<WarningKind>,
27
28 pub unexpected_fields: UnexpectedFields,
30
31 pub periods: Vec<Period>,
33
34 pub tariff_index: usize,
36
37 pub tariff_id: TariffId,
39
40 pub tariff_reports: Vec<(TariffId, UnexpectedFields)>,
44
45 pub timezone: String,
47
48 pub billed_energy: Kwh,
50
51 pub billed_parking_time: HoursDecimal,
53
54 pub total_charging_time: HoursDecimal,
59
60 pub billed_charging_time: HoursDecimal,
62
63 pub total_cost: Total<Price, Option<Price>>,
65
66 pub total_fixed_cost: Total<Option<Price>>,
68
69 pub total_time: Total<HoursDecimal>,
71
72 pub total_time_cost: Total<Option<Price>>,
74
75 pub total_energy: Total<Kwh>,
77
78 pub total_energy_cost: Total<Option<Price>>,
80
81 pub total_parking_time: Total<Option<HoursDecimal>, HoursDecimal>,
83
84 pub total_parking_cost: Total<Option<Price>>,
86
87 pub total_reservation_cost: Total<Option<Price>>,
89}
90
91#[derive(Debug, Serialize)]
92pub enum WarningKind {
93 PeriodsOutsideStartEndDateTime {
96 cdr_range: Range<DateTime>,
97 period_range: PeriodRange,
98 },
99}
100
101impl fmt::Display for WarningKind {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 match self {
104 Self::PeriodsOutsideStartEndDateTime {
105 cdr_range,
106 period_range,
107 } => {
108 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)
109 }
110 }
111 }
112}
113
114impl warning::Kind for WarningKind {
115 fn id(&self) -> Cow<'static, str> {
116 match self {
117 WarningKind::PeriodsOutsideStartEndDateTime { .. } => {
118 "periods_outside_start_end_date_time".into()
119 }
120 }
121 }
122}
123
124#[derive(Debug, Serialize)]
130pub struct Period {
131 pub start_date_time: DateTime,
133
134 pub end_date_time: DateTime,
136
137 pub dimensions: Dimensions,
139}
140
141impl Period {
142 pub fn new(period: &session::ChargePeriod, dimensions: Dimensions) -> Self {
143 Self {
144 start_date_time: period.start_instant.date_time,
145 end_date_time: period.end_instant.date_time,
146 dimensions,
147 }
148 }
149
150 pub fn cost(&self) -> Option<Price> {
152 [
153 self.dimensions.time.cost(),
154 self.dimensions.parking_time.cost(),
155 self.dimensions.flat.cost(),
156 self.dimensions.energy.cost(),
157 ]
158 .into_iter()
159 .fold(None, |accum, next| {
160 if accum.is_none() && next.is_none() {
161 None
162 } else {
163 Some(
164 accum
165 .unwrap_or_default()
166 .saturating_add(next.unwrap_or_default()),
167 )
168 }
169 })
170 }
171}
172
173#[derive(Debug, Serialize)]
175pub struct Dimensions {
176 pub flat: Dimension<()>,
178
179 pub energy: Dimension<Kwh>,
181
182 pub time: Dimension<HoursDecimal>,
184
185 pub parking_time: Dimension<HoursDecimal>,
187}
188
189impl Dimensions {
190 pub fn new(components: &tariff::PriceComponents, data: &session::PeriodData) -> Self {
191 Self {
192 parking_time: Dimension::new(components.parking, data.parking_duration.map(Into::into)),
193 time: Dimension::new(components.time, data.charging_duration.map(Into::into)),
194 energy: Dimension::new(components.energy, data.energy),
195 flat: Dimension::new(components.flat, Some(())),
196 }
197 }
198}
199
200#[derive(Debug, Serialize)]
201pub struct Dimension<V> {
203 pub price: Option<tariff::PriceComponent>,
207
208 pub volume: Option<V>,
212
213 pub billed_volume: Option<V>,
221}
222
223impl<V> Dimension<V>
224where
225 V: Copy,
226{
227 fn new(price_component: Option<tariff::PriceComponent>, volume: Option<V>) -> Self {
228 Self {
229 price: price_component,
230 volume,
231 billed_volume: volume,
232 }
233 }
234}
235
236impl<V: tariff::Dimension> Dimension<V> {
237 pub fn cost(&self) -> Option<Price> {
239 if let (Some(volume), Some(price)) = (self.billed_volume, self.price) {
240 let excl_vat = volume.cost(price.price);
241
242 let incl_vat = match price.vat {
243 CompatibilityVat::Vat(Some(vat)) => Some(excl_vat.apply_vat(vat)),
244 CompatibilityVat::Vat(None) => Some(excl_vat),
245 CompatibilityVat::Unknown => None,
246 };
247
248 Some(Price { excl_vat, incl_vat })
249 } else {
250 None
251 }
252 }
253}
254
255#[derive(Debug, Serialize)]
269pub struct Total<TCdr, TCalc = TCdr> {
270 pub cdr: TCdr,
272
273 pub calculated: TCalc,
275}
276
277#[derive(Debug)]
279pub enum Error {
280 Deserialize(ParseError),
282
283 DimensionShouldHaveVolume {
285 dimension_name: &'static str,
286 },
287
288 DurationOverflow,
290
291 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
293
294 NoValidTariff,
304
305 Tariff(tariff::Error),
307}
308
309impl From<InvalidPeriodIndex> for Error {
310 fn from(err: InvalidPeriodIndex) -> Self {
311 Self::Internal(err.into())
312 }
313}
314
315#[derive(Debug)]
316struct InvalidPeriodIndex(&'static str);
317
318impl std::error::Error for InvalidPeriodIndex {}
319
320impl fmt::Display for InvalidPeriodIndex {
321 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322 write!(f, "Invalid index for period `{}`", self.0)
323 }
324}
325#[derive(Debug, Serialize)]
326pub enum PeriodRange {
327 Many(Range<DateTime>),
330
331 Single(DateTime),
333}
334
335impl fmt::Display for PeriodRange {
336 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337 match self {
338 PeriodRange::Many(Range { start, end }) => write!(f, "{start}-{end}"),
339 PeriodRange::Single(date_time) => write!(f, "{date_time}"),
340 }
341 }
342}
343
344impl From<ParseError> for Error {
345 fn from(err: ParseError) -> Self {
346 Error::Deserialize(err)
347 }
348}
349
350impl From<tariff::Error> for Error {
351 fn from(err: tariff::Error) -> Self {
352 Error::Tariff(err)
353 }
354}
355
356impl From<duration::Error> for Error {
357 fn from(err: duration::Error) -> Self {
358 match err {
359 duration::Error::DurationOverflow => Self::DurationOverflow,
360 }
361 }
362}
363
364impl std::error::Error for Error {
365 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
366 match self {
367 Error::Internal(err) => Some(&**err),
368 Error::Tariff(err) => Some(err),
369 _ => None,
370 }
371 }
372}
373
374impl fmt::Display for Error {
375 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376 match self {
377 Self::Deserialize(err) => {
378 write!(f, "{err}")
379 }
380 Self::DimensionShouldHaveVolume { dimension_name } => {
381 write!(f, "Dimension `{dimension_name}` should have volume")
382 }
383 Self::DurationOverflow => {
384 f.write_str("A numeric overflow occurred while creating a duration")
385 }
386 Self::Internal(err) => {
387 write!(f, "Internal: {err}")
388 }
389 Self::NoValidTariff => {
390 f.write_str("No valid tariff has been found in the list of provided tariffs")
391 }
392 Self::Tariff(err) => {
393 write!(f, "{err}")
394 }
395 }
396 }
397}
398
399#[derive(Debug)]
400enum InternalError {
401 InvalidPeriodIndex {
402 index: usize,
403 field_name: &'static str,
404 },
405}
406
407impl std::error::Error for InternalError {}
408
409impl From<InternalError> for Error {
410 fn from(err: InternalError) -> Self {
411 Error::Internal(Box::new(err))
412 }
413}
414
415impl fmt::Display for InternalError {
416 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417 match self {
418 InternalError::InvalidPeriodIndex { field_name, index } => {
419 write!(
420 f,
421 "Invalid period index for `{field_name}`; index: `{index}`"
422 )
423 }
424 }
425 }
426}
427
428#[derive(Debug)]
432pub enum TariffSource {
433 UseCdr,
435
436 Override(Vec<String>),
438}
439
440#[instrument(skip_all)]
441pub(crate) fn price_cdr(
442 cdr_json: &str,
443 tariff_source: TariffSource,
444 timezone: Tz,
445 version: Version,
446) -> Result<Report, Error> {
447 let cdr_deser = cdr_from_str(cdr_json, version)?;
448 let DeserCdr {
449 cdr,
450 unexpected_fields,
451 } = cdr_deser;
452
453 match tariff_source {
454 TariffSource::UseCdr => {
455 debug!("Using tariffs from CDR");
456 let tariffs = cdr
457 .tariffs
458 .iter()
459 .map(|json| tariff_from_str(json.get(), version))
460 .collect::<Result<Vec<_>, _>>()?;
461 let report =
462 price_cdr_with_tariffs(&cdr, &unexpected_fields, tariffs, timezone, version)?;
463 Ok(report)
464 }
465 TariffSource::Override(tariffs) => {
466 debug!("Using override tariffs");
467 let tariffs = tariffs
468 .iter()
469 .map(|json| tariff_from_str(json, version))
470 .collect::<Result<Vec<_>, _>>()?;
471 let report =
472 price_cdr_with_tariffs(&cdr, &unexpected_fields, tariffs, timezone, version)?;
473 Ok(report)
474 }
475 }
476}
477
478fn price_cdr_with_tariffs<'a>(
482 cdr: &v221::Cdr<'a>,
483 unexpected_fields: &UnexpectedFields,
484 tariffs: Vec<DeserTariff<'a>>,
485 timezone: Tz,
486 version: Version,
487) -> Result<Report, Error> {
488 debug!(?timezone, ?version, "Pricing CDR");
489
490 let warnings = validate_cdr(cdr);
491
492 let tariff_reports = process_tariffs(tariffs)?;
493 debug!(tariffs = ?tariff_reports.iter().map(|report| report.tariff.id()).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
494
495 let tariff = find_first_active_tariff(&tariff_reports, cdr.start_date_time)
496 .ok_or(Error::NoValidTariff)?;
497
498 let (tariff_index, tariff) = tariff;
499
500 debug!(
501 id = tariff.id(),
502 index = tariff_index,
503 "Found active tariff"
504 );
505
506 debug!(%timezone, "Found timezone");
507
508 debug!("Extracting charge periods");
509 let cs_periods = session::extract_periods(cdr, timezone)?;
510
511 debug!(count = cs_periods.len(), "Found CDR periods");
512
513 trace!("# CDR period list:");
514 for period in &cs_periods {
515 trace!("{period:#?}");
516 }
517
518 let mut periods = Vec::new();
519 let mut step_size = StepSize::new();
520 let mut total_energy = Kwh::zero();
521 let mut total_charging_time = HoursDecimal::zero();
522 let mut total_parking_time = HoursDecimal::zero();
523
524 let mut has_flat_fee = false;
525
526 debug!(
527 tariff_id = tariff.id(),
528 period_count = periods.len(),
529 "Accumulating `total_charging_time`, `total_energy` and `total_parking_time`"
530 );
531
532 for (index, period) in cs_periods.iter().enumerate() {
533 let mut components = tariff.active_components(period);
534 trace!(
535 index,
536 "Creating charge period with Dimension\n{period:#?}\n{components:#?}"
537 );
538
539 if components.flat.is_some() {
540 if has_flat_fee {
541 components.flat = None;
542 } else {
543 has_flat_fee = true;
544 }
545 }
546
547 step_size.update(index, &components, period);
548
549 trace!(period_index = index, "Step size updated\n{step_size:#?}");
550
551 let dimensions = Dimensions::new(&components, &period.period_data);
552
553 trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
554
555 total_charging_time = total_charging_time
556 .saturating_add(dimensions.time.volume.unwrap_or_else(HoursDecimal::zero));
557
558 total_energy =
559 total_energy.saturating_add(dimensions.energy.volume.unwrap_or_else(Kwh::zero));
560
561 total_parking_time = total_parking_time.saturating_add(
562 dimensions
563 .parking_time
564 .volume
565 .unwrap_or_else(HoursDecimal::zero),
566 );
567
568 trace!(period_index = index, "Update totals");
569 trace!("total_charging_time: {total_charging_time:#?}");
570 trace!("total_energy: {total_energy:#?}");
571 trace!("total_parking_time: {total_parking_time:#?}");
572
573 periods.push(Period::new(period, dimensions));
574 }
575
576 let billed_charging_time = step_size.apply_time(&mut periods, total_charging_time)?;
577 let billed_energy = step_size.apply_energy(&mut periods, total_energy)?;
578 let billed_parking_time = step_size.apply_parking_time(&mut periods, total_parking_time)?;
579
580 trace!("Update billed totals");
581 trace!("billed_charging_time: {billed_charging_time:#?}");
582 trace!("billed_energy: {billed_energy:#?}");
583 trace!("billed_parking_time: {billed_parking_time:#?}");
584
585 let mut total_energy_cost: Option<Price> = None;
586 let mut total_fixed_cost: Option<Price> = None;
587 let mut total_parking_cost: Option<Price> = None;
588 let mut total_time_cost: Option<Price> = None;
589
590 debug!(
591 tariff_id = tariff.id(),
592 period_count = periods.len(),
593 "Accumulating `total_energy_cost`, `total_fixed_cost`, `total_parking_cost` and `total_time_cost`"
594 );
595 for (index, period) in periods.iter().enumerate() {
596 let dimensions = &period.dimensions;
597
598 trace!(period_index = index, "Processing period");
599 total_energy_cost = match (total_energy_cost, dimensions.energy.cost()) {
600 (None, None) => None,
601 (total, period) => Some(
602 total
603 .unwrap_or_default()
604 .saturating_add(period.unwrap_or_default()),
605 ),
606 };
607
608 total_time_cost = match (total_time_cost, dimensions.time.cost()) {
609 (None, None) => None,
610 (total, period) => Some(
611 total
612 .unwrap_or_default()
613 .saturating_add(period.unwrap_or_default()),
614 ),
615 };
616
617 total_parking_cost = match (total_parking_cost, dimensions.parking_time.cost()) {
618 (None, None) => None,
619 (total, period) => Some(
620 total
621 .unwrap_or_default()
622 .saturating_add(period.unwrap_or_default()),
623 ),
624 };
625
626 total_fixed_cost = match (total_fixed_cost, dimensions.flat.cost()) {
627 (None, None) => None,
628 (total, period) => Some(
629 total
630 .unwrap_or_default()
631 .saturating_add(period.unwrap_or_default()),
632 ),
633 };
634
635 trace!(period_index = index, "Update totals");
636 trace!("total_energy_cost = {total_energy_cost:?}");
637 trace!("total_fixed_cost = {total_fixed_cost:?}");
638 trace!("total_parking_cost = {total_parking_cost:?}");
639 trace!("total_time_cost = {total_time_cost:?}");
640 }
641
642 trace!("Calculating `total_cost` by accumulating `total_energy_cost`, `total_fixed_cost`, `total_parking_cost` and `total_time_cost`");
643 trace!("total_energy_cost = {total_energy_cost:?}");
644 trace!("total_fixed_cost = {total_fixed_cost:?}");
645 trace!("total_parking_cost = {total_parking_cost:?}");
646 trace!("total_time_cost = {total_time_cost:?}");
647 debug!(
648 ?total_energy_cost,
649 ?total_fixed_cost,
650 ?total_parking_cost,
651 ?total_time_cost,
652 "Calculating `total_cost`"
653 );
654
655 let total_cost = [
656 total_energy_cost,
657 total_fixed_cost,
658 total_parking_cost,
659 total_time_cost,
660 ]
661 .into_iter()
662 .fold(None, |accum: Option<Price>, next| match (accum, next) {
663 (None, None) => None,
664 _ => Some(
665 accum
666 .unwrap_or_default()
667 .saturating_add(next.unwrap_or_default()),
668 ),
669 });
670
671 debug!(?total_cost);
672
673 let total_time = {
674 debug!(
675 period_start = ?periods.first().map(|p| p.start_date_time),
676 period_end = ?periods.last().map(|p| p.end_date_time),
677 "Calculating `total_time`"
678 );
679 if let Some((first, last)) = periods.first().zip(periods.last()) {
680 let time_delta = last
681 .end_date_time
682 .signed_duration_since(*first.start_date_time);
683
684 time_delta.into()
685 } else {
686 HoursDecimal::zero()
687 }
688 };
689 debug!(%total_time);
690
691 let report = Report {
692 periods,
693 tariff_index,
694 tariff_id: tariff.id().to_string(),
695 timezone: timezone.to_string(),
696 billed_parking_time,
697 billed_energy,
698 billed_charging_time,
699 unexpected_fields: unexpected_fields.clone(),
700 tariff_reports: tariff_reports
701 .into_iter()
702 .map(
703 |TariffReport {
704 tariff,
705 unexpected_fields,
706 }| (tariff.id().to_string(), unexpected_fields),
707 )
708 .collect(),
709 total_charging_time,
710 total_cost: Total {
711 cdr: cdr.total_cost,
712 calculated: total_cost,
713 },
714 total_time_cost: Total {
715 cdr: cdr.total_time_cost,
716 calculated: total_time_cost,
717 },
718 total_time: Total {
719 cdr: cdr.total_time,
720 calculated: total_time,
721 },
722 total_parking_cost: Total {
723 cdr: cdr.total_parking_cost,
724 calculated: total_parking_cost,
725 },
726 total_parking_time: Total {
727 cdr: cdr.total_parking_time,
728 calculated: total_parking_time,
729 },
730 total_energy_cost: Total {
731 cdr: cdr.total_energy_cost,
732 calculated: total_energy_cost,
733 },
734 total_energy: Total {
735 cdr: cdr.total_energy,
736 calculated: total_energy,
737 },
738 total_fixed_cost: Total {
739 cdr: cdr.total_fixed_cost,
740 calculated: total_fixed_cost,
741 },
742 total_reservation_cost: Total {
743 cdr: cdr.total_reservation_cost,
744 calculated: None,
745 },
746 warnings,
747 };
748
749 trace!("{report:#?}");
750
751 Ok(report)
752}
753
754fn validate_cdr(cdr: &v221::Cdr<'_>) -> Vec<WarningKind> {
756 let mut warnings = vec![];
757 let cdr_range = cdr.start_date_time..cdr.end_date_time;
758 let periods: Vec<_> = cdr.charging_periods.iter().collect();
759
760 if let Ok([period]) = TryInto::<[_; 1]>::try_into(periods.as_ref()) {
761 if !cdr_range.contains(&period.start_date_time) {
762 warnings.push(WarningKind::PeriodsOutsideStartEndDateTime {
763 cdr_range,
764 period_range: PeriodRange::Single(period.start_date_time),
765 });
766 }
767 } else if let Ok([period_a, period_b]) = TryInto::<[_; 2]>::try_into(periods.as_ref()) {
768 let period_range = period_a.start_date_time..period_b.start_date_time;
769
770 if !(cdr_range.contains(&period_range.start) && cdr_range.contains(&period_range.end)) {
771 warnings.push(WarningKind::PeriodsOutsideStartEndDateTime {
772 cdr_range,
773 period_range: PeriodRange::Many(period_range),
774 });
775 }
776 }
777
778 warnings
779}
780
781struct TariffReport<'a> {
783 tariff: Tariff<'a>,
785
786 unexpected_fields: UnexpectedFields,
788}
789
790fn process_tariffs(deser_tariffs: Vec<DeserTariff<'_>>) -> Result<Vec<TariffReport<'_>>, Error> {
793 let mut tariff_reports = vec![];
794
795 for tariff in deser_tariffs {
796 let DeserTariff {
797 tariff,
798 unexpected_fields,
799 } = tariff;
800
801 let tariff = Tariff::new(&tariff)?;
802
803 tariff_reports.push(TariffReport {
804 tariff,
805 unexpected_fields,
806 });
807 }
808
809 Ok(tariff_reports)
810}
811
812fn find_first_active_tariff<'a>(
814 tariffs: &'a [TariffReport<'a>],
815 start_date_time: DateTime,
816) -> Option<(usize, &'a Tariff<'a>)> {
817 let tariffs: Vec<_> = tariffs.iter().map(|report| &report.tariff).collect();
818
819 tariffs
820 .into_iter()
821 .enumerate()
822 .find(|(_, t)| t.is_active(start_date_time))
823}
824
825#[derive(Debug)]
826struct StepSize {
827 time: Option<(usize, tariff::PriceComponent)>,
828 parking_time: Option<(usize, tariff::PriceComponent)>,
829 energy: Option<(usize, tariff::PriceComponent)>,
830}
831
832impl StepSize {
833 fn new() -> Self {
834 Self {
835 time: None,
836 parking_time: None,
837 energy: None,
838 }
839 }
840
841 fn update(
842 &mut self,
843 index: usize,
844 components: &tariff::PriceComponents,
845 period: &session::ChargePeriod,
846 ) {
847 if period.period_data.energy.is_some() {
848 if let Some(energy) = components.energy {
849 self.energy = Some((index, energy));
850 }
851 }
852
853 if period.period_data.charging_duration.is_some() {
854 if let Some(time) = components.time {
855 self.time = Some((index, time));
856 }
857 }
858
859 if period.period_data.parking_duration.is_some() {
860 if let Some(parking) = components.parking {
861 self.parking_time = Some((index, parking));
862 }
863 }
864 }
865
866 fn duration_step_size(
867 total_volume: HoursDecimal,
868 period_billed_volume: &mut HoursDecimal,
869 step_size: u64,
870 ) -> Result<HoursDecimal, Error> {
871 if step_size == 0 {
872 return Ok(total_volume);
873 }
874
875 let total_seconds = total_volume.as_num_seconds_number();
876 let step_size = Number::from(step_size);
877
878 let total_billed_volume = HoursDecimal::from_seconds_number(
879 total_seconds
880 .checked_div(step_size)
881 .ok_or(Error::DurationOverflow)?
882 .ceil()
883 .saturating_mul(step_size),
884 )?;
885
886 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
887 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
888
889 Ok(total_billed_volume)
890 }
891
892 fn apply_time(
893 &self,
894 periods: &mut [Period],
895 total: HoursDecimal,
896 ) -> Result<HoursDecimal, Error> {
897 let (Some((time_index, price)), None) = (&self.time, &self.parking_time) else {
898 return Ok(total);
899 };
900
901 let Some(period) = periods.get_mut(*time_index) else {
902 return Err(InternalError::InvalidPeriodIndex {
903 index: *time_index,
904 field_name: "apply_time",
905 }
906 .into());
907 };
908 let volume = period.dimensions.time.billed_volume.as_mut().ok_or(
909 Error::DimensionShouldHaveVolume {
910 dimension_name: "time",
911 },
912 )?;
913
914 Self::duration_step_size(total, volume, price.step_size)
915 }
916
917 fn apply_parking_time(
918 &self,
919 periods: &mut [Period],
920 total: HoursDecimal,
921 ) -> Result<HoursDecimal, Error> {
922 let Some((parking_index, price)) = &self.parking_time else {
923 return Ok(total);
924 };
925
926 let Some(period) = periods.get_mut(*parking_index) else {
927 return Err(InternalError::InvalidPeriodIndex {
928 index: *parking_index,
929 field_name: "apply_parking_time",
930 }
931 .into());
932 };
933 let volume = period
934 .dimensions
935 .parking_time
936 .billed_volume
937 .as_mut()
938 .ok_or(Error::DimensionShouldHaveVolume {
939 dimension_name: "parking_time",
940 })?;
941
942 Self::duration_step_size(total, volume, price.step_size)
943 }
944
945 fn apply_energy(&self, periods: &mut [Period], total_volume: Kwh) -> Result<Kwh, Error> {
946 let Some((energy_index, price)) = &self.energy else {
947 return Ok(total_volume);
948 };
949
950 if price.step_size == 0 {
951 return Ok(total_volume);
952 }
953
954 let Some(period) = periods.get_mut(*energy_index) else {
955 return Err(InternalError::InvalidPeriodIndex {
956 index: *energy_index,
957 field_name: "apply_energy",
958 }
959 .into());
960 };
961 let step_size = Number::from(price.step_size);
962
963 let period_billed_volume = period.dimensions.energy.billed_volume.as_mut().ok_or(
964 Error::DimensionShouldHaveVolume {
965 dimension_name: "energy",
966 },
967 )?;
968
969 let total_billed_volume = Kwh::from_watt_hours(
970 total_volume
971 .watt_hours()
972 .checked_div(step_size)
973 .ok_or(Error::DurationOverflow)?
974 .ceil()
975 .saturating_mul(step_size),
976 );
977
978 let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
979 *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
980
981 Ok(total_billed_volume)
982 }
983}
984
985#[derive(Debug)]
987struct DeserCdr<'a> {
988 cdr: v221::Cdr<'a>,
989 unexpected_fields: UnexpectedFields,
990}
991
992fn cdr_from_str<'a>(json: &'a str, version: Version) -> Result<DeserCdr<'a>, ParseError> {
993 match version {
994 Version::V221 => {
995 let (cdr, unexpected_fields) =
996 obj_from_json_str::<v221::Cdr<'a>>(json).map_err(ParseError::from_cdr_serde_err)?;
997 Ok(DeserCdr {
998 cdr,
999 unexpected_fields,
1000 })
1001 }
1002 Version::V211 => {
1003 let (cdr, unexpected_fields) =
1004 obj_from_json_str::<v211::Cdr<'_>>(json).map_err(ParseError::from_cdr_serde_err)?;
1005 Ok(DeserCdr {
1006 cdr: cdr.into(),
1007 unexpected_fields,
1008 })
1009 }
1010 }
1011}
1012
1013#[derive(Debug)]
1015struct DeserTariff<'a> {
1016 tariff: v221::Tariff<'a>,
1017
1018 unexpected_fields: UnexpectedFields,
1020}
1021
1022fn tariff_from_str<'a>(json: &'a str, version: Version) -> Result<DeserTariff<'a>, ParseError> {
1023 match version {
1024 Version::V221 => {
1025 let (tariff, unexpected_fields) = obj_from_json_str::<v221::Tariff<'a>>(json)
1026 .map_err(ParseError::from_tariff_serde_err)?;
1027 Ok(DeserTariff {
1028 tariff,
1029 unexpected_fields,
1030 })
1031 }
1032 Version::V211 => {
1033 let (tariff, unexpected_fields) = obj_from_json_str::<v211::Tariff<'a>>(json)
1034 .map_err(ParseError::from_tariff_serde_err)?;
1035 Ok(DeserTariff {
1036 tariff: tariff.into(),
1037 unexpected_fields,
1038 })
1039 }
1040 }
1041}
1042
1043#[cfg(test)]
1044pub mod test {
1045 #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
1046 #![allow(clippy::panic, reason = "tests are allowed panic")]
1047
1048 use std::collections::BTreeMap;
1049
1050 use tracing::debug;
1051
1052 use crate::{
1053 price::Total, test::Expectation, timezone, warning::Kind, HoursDecimal, Kwh, Price,
1054 };
1055
1056 use super::{Error, Report};
1057
1058 const PRECISION: u32 = 2;
1060
1061 #[test]
1062 const fn error_should_be_send_and_sync() {
1063 const fn f<T: Send + Sync>() {}
1064
1065 f::<Error>();
1066 }
1067
1068 #[track_caller]
1070 pub fn parse_expect_json(
1071 expect_json: Option<&str>,
1072 ) -> (
1073 Option<timezone::test::FindOrInferExpect>,
1074 Option<PriceExpect>,
1075 ) {
1076 let expect = expect_json
1077 .map(|json| serde_json::from_str(json).expect("Unable to parse expect JSON"));
1078 expect
1079 .map(|v| {
1080 let Expect {
1081 timezone_find,
1082 cdr_price,
1083 } = v;
1084 (timezone_find, cdr_price)
1085 })
1086 .unwrap_or_default()
1087 }
1088
1089 #[derive(serde::Deserialize)]
1090 pub struct Expect {
1091 pub timezone_find: Option<timezone::test::FindOrInferExpect>,
1093
1094 pub cdr_price: Option<PriceExpect>,
1095 }
1096
1097 pub(crate) fn assert_price_report(report: Report, cdr_price_expect: Option<PriceExpect>) {
1098 let Report {
1099 warnings,
1100 unexpected_fields,
1101 tariff_reports,
1102 periods: _,
1103 tariff_index,
1104 tariff_id,
1105 timezone: _,
1106 billed_energy: _,
1107 billed_parking_time: _,
1108 billed_charging_time: _,
1109 total_charging_time: _,
1110 total_cost,
1111 total_fixed_cost,
1112 total_time,
1113 total_time_cost,
1114 total_energy,
1115 total_energy_cost,
1116 total_parking_time,
1117 total_parking_cost,
1118 total_reservation_cost,
1119 } = report;
1120
1121 let (
1124 warnings_expect,
1125 unexpected_fields_expect,
1126 tariff_index_expect,
1127 tariff_id_expect,
1128 tariff_reports_expect,
1129 total_cost_expectation,
1130 total_fixed_cost_expectation,
1131 total_time_expectation,
1132 total_time_cost_expectation,
1133 total_energy_expectation,
1134 total_energy_cost_expectation,
1135 total_parking_time_expectation,
1136 total_parking_cost_expectation,
1137 total_reservation_cost_expectation,
1138 ) = cdr_price_expect
1139 .map(|exp| {
1140 let PriceExpect {
1141 warnings,
1142 unexpected_fields,
1143 tariff_index,
1144 tariff_id,
1145 tariff_reports,
1146 total_cost,
1147 total_fixed_cost,
1148 total_time,
1149 total_time_cost,
1150 total_energy,
1151 total_energy_cost,
1152 total_parking_time,
1153 total_parking_cost,
1154 total_reservation_cost,
1155 } = exp;
1156
1157 (
1158 warnings,
1159 unexpected_fields,
1160 tariff_index,
1161 tariff_id,
1162 tariff_reports,
1163 total_cost,
1164 total_fixed_cost,
1165 total_time,
1166 total_time_cost,
1167 total_energy,
1168 total_energy_cost,
1169 total_parking_time,
1170 total_parking_cost,
1171 total_reservation_cost,
1172 )
1173 })
1174 .unwrap_or((
1175 Expectation::Absent,
1176 Expectation::Absent,
1177 Expectation::Absent,
1178 Expectation::Absent,
1179 Expectation::Absent,
1180 Expectation::Absent,
1181 Expectation::Absent,
1182 Expectation::Absent,
1183 Expectation::Absent,
1184 Expectation::Absent,
1185 Expectation::Absent,
1186 Expectation::Absent,
1187 Expectation::Absent,
1188 Expectation::Absent,
1189 ));
1190
1191 if let Expectation::Present(expectation) = warnings_expect {
1192 let warnings_expect = expectation.expect_value();
1193
1194 debug!("{warnings_expect:?}");
1195
1196 for warning in warnings {
1197 assert!(
1198 warnings_expect.contains(&warning.id().to_string()),
1199 "The CDR has a warning that's not expected"
1200 );
1201 }
1202 } else {
1203 assert!(warnings.is_empty(), "The CDR has warnings; {warnings:?}",);
1204 }
1205
1206 if let Expectation::Present(expectation) = unexpected_fields_expect {
1207 let unexpected_fields_expect = expectation.expect_value();
1208
1209 for field in unexpected_fields {
1210 assert!(
1211 unexpected_fields_expect.contains(&field),
1212 "The CDR has an unexpected field that's not expected: `{field}`"
1213 );
1214 }
1215 } else {
1216 assert!(
1217 unexpected_fields.is_empty(),
1218 "The CDR has unexpected fields; {unexpected_fields:?}",
1219 );
1220 }
1221
1222 if let Expectation::Present(expectation) = tariff_reports_expect {
1223 let tariff_reports_expect: BTreeMap<_, _> = expectation
1224 .expect_value()
1225 .into_iter()
1226 .map(
1227 |TariffReport {
1228 id,
1229 unexpected_fields,
1230 }| (id, unexpected_fields),
1231 )
1232 .collect();
1233
1234 for (tariff_id, mut unexpected_fields) in tariff_reports {
1235 let Some(unexpected_fields_expect) = tariff_reports_expect.get(&*tariff_id) else {
1236 panic!("A tariff with {tariff_id} is not expected");
1237 };
1238
1239 debug!("{:?}", unexpected_fields_expect);
1240
1241 unexpected_fields.retain(|field| {
1242 let present = unexpected_fields_expect.contains(field);
1243 assert!(present, "The tariff with id: `{tariff_id}` has an unexpected field that is not expected: `{field}`");
1244 !present
1245 });
1246
1247 assert!(
1248 unexpected_fields.is_empty(),
1249 "The tariff with id `{tariff_id}` has unexpected fields; {unexpected_fields:?}",
1250 );
1251 }
1252 } else {
1253 for (id, unexpected_fields) in tariff_reports {
1254 assert!(
1255 unexpected_fields.is_empty(),
1256 "The tariff with id `{id}` has unexpected fields; {unexpected_fields:?}",
1257 );
1258 }
1259 }
1260
1261 if let Expectation::Present(expectation) = tariff_id_expect {
1262 assert_eq!(tariff_id, expectation.expect_value());
1263 }
1264
1265 if let Expectation::Present(expectation) = tariff_index_expect {
1266 assert_eq!(tariff_index, expectation.expect_value());
1267 }
1268
1269 total_cost_expectation.expect_price("total_cost", &total_cost);
1270 total_fixed_cost_expectation.expect_opt_price("total_fixed_cost", &total_fixed_cost);
1271 total_time_expectation.expect_duration("total_time", &total_time);
1272 total_time_cost_expectation.expect_opt_price("total_time_cost", &total_time_cost);
1273 total_energy_expectation.expect_kwh("total_energy", &total_energy);
1274 total_energy_cost_expectation.expect_opt_price("total_energy_cost", &total_energy_cost);
1275 total_parking_time_expectation
1276 .expect_opt_duration("total_parking_time", &total_parking_time);
1277 total_parking_cost_expectation.expect_opt_price("total_parking_cost", &total_parking_cost);
1278 total_reservation_cost_expectation
1279 .expect_opt_price("total_reservation_cost", &total_reservation_cost);
1280 }
1281
1282 #[derive(serde::Deserialize)]
1284 pub struct PriceExpect {
1285 #[serde(default)]
1286 warnings: Expectation<Vec<String>>,
1287
1288 #[serde(default)]
1289 unexpected_fields: Expectation<Vec<String>>,
1290
1291 #[serde(default)]
1293 tariff_index: Expectation<usize>,
1294
1295 #[serde(default)]
1297 tariff_id: Expectation<String>,
1298
1299 #[serde(default)]
1303 tariff_reports: Expectation<Vec<TariffReport>>,
1304
1305 #[serde(default)]
1307 total_cost: Expectation<Price>,
1308
1309 #[serde(default)]
1311 total_fixed_cost: Expectation<Price>,
1312
1313 #[serde(default)]
1315 total_time: Expectation<HoursDecimal>,
1316
1317 #[serde(default)]
1319 total_time_cost: Expectation<Price>,
1320
1321 #[serde(default)]
1323 total_energy: Expectation<Kwh>,
1324
1325 #[serde(default)]
1327 total_energy_cost: Expectation<Price>,
1328
1329 #[serde(default)]
1331 total_parking_time: Expectation<HoursDecimal>,
1332
1333 #[serde(default)]
1335 total_parking_cost: Expectation<Price>,
1336
1337 #[serde(default)]
1339 total_reservation_cost: Expectation<Price>,
1340 }
1341
1342 #[derive(Debug, serde::Deserialize)]
1343 pub struct TariffReport {
1344 id: String,
1345
1346 unexpected_fields: Vec<String>,
1347 }
1348
1349 impl Expectation<Price> {
1350 #[track_caller]
1351 fn expect_opt_price(self, field_name: &str, total: &Total<Option<Price>>) {
1352 if let Expectation::Present(expect_value) = self {
1353 assert_eq!(
1354 expect_value.into_option(),
1355 total.calculated.map(|v| v.rescale().round_dp(PRECISION)),
1356 "Comparing `{field_name}` field with expectation"
1357 );
1358 } else {
1359 match (total.cdr, total.calculated) {
1360 (None, None) => (),
1361 (None, Some(calculated)) => {
1362 assert!(calculated.is_zero(), "The CDR field `{field_name}` doesn't have a value but a value was calculated; calculated: {calculated:?}");
1363 }
1364 (Some(cdr), None) => {
1365 assert!(
1366 cdr.is_zero(),
1367 "The CDR field `{field_name}` has a value but the calculated value is none; cdr: {cdr:?}"
1368 );
1369 }
1370 (Some(cdr), Some(calculated)) => {
1371 assert_eq!(
1372 cdr.round_dp(PRECISION),
1373 calculated.rescale().round_dp(PRECISION),
1374 "Comparing `{field_name}` field with CDR"
1375 );
1376 }
1377 }
1378 }
1379 }
1380
1381 #[track_caller]
1382 fn expect_price(self, field_name: &str, total: &Total<Price, Option<Price>>) {
1383 if let Expectation::Present(expect_value) = self {
1384 assert_eq!(
1385 expect_value.into_option(),
1386 total.calculated.map(|v| v.rescale().round_dp(PRECISION)),
1387 "Comparing `{field_name}` field with expectation"
1388 );
1389 } else if let Some(calculated) = total.calculated {
1390 assert_eq!(
1391 total.cdr.round_dp(PRECISION),
1392 calculated.rescale().round_dp(PRECISION),
1393 "Comparing `{field_name}` field with CDR"
1394 );
1395 } else {
1396 assert!(
1397 total.cdr.is_zero(),
1398 "The CDR field `{field_name}` has a value but the calculated value is none; cdr: {:?}",
1399 total.cdr
1400 );
1401 }
1402 }
1403 }
1404
1405 impl Expectation<HoursDecimal> {
1406 #[track_caller]
1407 fn expect_duration(self, field_name: &str, total: &Total<HoursDecimal>) {
1408 if let Expectation::Present(expect_value) = self {
1409 assert_eq!(
1410 expect_value.expect_value().as_num_hours_number(),
1411 total.calculated.as_num_hours_number().round_dp(PRECISION),
1412 "Comparing `{field_name}` field with expectation"
1413 );
1414 } else {
1415 assert_eq!(
1416 total.cdr.as_num_hours_number().round_dp(PRECISION),
1417 total.calculated.as_num_hours_number().round_dp(PRECISION),
1418 "Comparing `{field_name}` field with CDR"
1419 );
1420 }
1421 }
1422
1423 #[track_caller]
1424 fn expect_opt_duration(
1425 self,
1426 field_name: &str,
1427 total: &Total<Option<HoursDecimal>, HoursDecimal>,
1428 ) {
1429 if let Expectation::Present(expect_value) = self {
1430 assert_eq!(
1431 expect_value.expect_value().as_num_hours_number(),
1432 total.calculated.as_num_hours_number().round_dp(PRECISION),
1433 "Comparing `{field_name}` field with expectation"
1434 );
1435 } else {
1436 assert_eq!(
1437 total
1438 .cdr
1439 .unwrap_or_default()
1440 .as_num_hours_number()
1441 .round_dp(PRECISION),
1442 total.calculated.as_num_hours_number().round_dp(PRECISION),
1443 "Comparing `{field_name}` field with CDR"
1444 );
1445 }
1446 }
1447 }
1448
1449 impl Expectation<Kwh> {
1450 #[track_caller]
1451 fn expect_kwh(self, field_name: &str, total: &Total<Kwh>) {
1452 if let Expectation::Present(expect_value) = self {
1453 assert_eq!(
1454 expect_value.expect_value().round_dp(PRECISION),
1455 total.calculated.rescale().round_dp(PRECISION),
1456 "Comparing `{field_name}` field with expectation"
1457 );
1458 } else {
1459 assert_eq!(
1460 total.cdr.round_dp(PRECISION),
1461 total.calculated.rescale().round_dp(PRECISION),
1462 "Comparing `{field_name}` field with CDR"
1463 );
1464 }
1465 }
1466 }
1467}
1468
1469#[cfg(test)]
1470mod test_validate_cdr {
1471 use assert_matches::assert_matches;
1472
1473 use crate::{
1474 de::obj_from_json_str,
1475 price::{self, v221, WarningKind},
1476 test::{self, datetime_from_str},
1477 };
1478
1479 use super::validate_cdr;
1480
1481 #[test]
1482 fn should_pass_validation() {
1483 test::setup();
1484 let json = cdr_json("2022-01-13T16:00:00Z", "2022-01-13T19:12:00Z");
1485 let (cdr, _) = obj_from_json_str::<v221::Cdr<'_>>(&json).unwrap();
1486
1487 let warnings = validate_cdr(&cdr);
1488 assert!(warnings.is_empty());
1489 }
1490
1491 #[test]
1492 fn should_fail_validation_start_end_range_doesnt_overlap_with_periods() {
1493 test::setup();
1494
1495 let json = cdr_json("2022-02-13T16:00:00Z", "2022-02-13T19:12:00Z");
1496 let (cdr, _) = obj_from_json_str::<v221::Cdr<'_>>(&json).unwrap();
1497
1498 let warnings = validate_cdr(&cdr);
1499 let [warning] = warnings.try_into().unwrap();
1500 let (cdr_range, period_range) = assert_matches!(warning, WarningKind::PeriodsOutsideStartEndDateTime { cdr_range, period_range } => (cdr_range, period_range));
1501 {
1502 assert_eq!(cdr_range.start, datetime_from_str("2022-02-13T16:00:00Z"));
1503 assert_eq!(cdr_range.end, datetime_from_str("2022-02-13T19:12:00Z"));
1504 }
1505 {
1506 let period_range =
1507 assert_matches!(period_range, price::PeriodRange::Many(range) => range);
1508
1509 assert_eq!(
1510 period_range.start,
1511 datetime_from_str("2022-01-13T16:00:00Z")
1512 );
1513 assert_eq!(period_range.end, datetime_from_str("2022-01-13T18:30:00Z"));
1514 }
1515 }
1516
1517 fn cdr_json(start_date_time: &str, end_date_time: &str) -> String {
1518 let value = serde_json::json!({
1519 "start_date_time": start_date_time,
1520 "end_date_time": end_date_time,
1521 "currency": "EUR",
1522 "tariffs": [],
1523 "cdr_location": {
1524 "country": "NLD"
1525 },
1526 "charging_periods": [
1527 {
1528 "start_date_time": "2022-01-13T16:00:00Z",
1529 "dimensions": [
1530 {
1531 "type": "TIME",
1532 "volume": 2.5
1533 }
1534 ]
1535 },
1536 {
1537 "start_date_time": "2022-01-13T18:30:00Z",
1538 "dimensions": [
1539 {
1540 "type": "PARKING_TIME",
1541 "volume": 0.7
1542 }
1543 ]
1544 }
1545 ],
1546 "total_cost": {
1547 "excl_vat": 11.25,
1548 "incl_vat": 12.75
1549 },
1550 "total_time_cost": {
1551 "excl_vat": 7.5,
1552 "incl_vat": 8.25
1553 },
1554 "total_parking_time": 0.7,
1555 "total_parking_cost": {
1556 "excl_vat": 3.75,
1557 "incl_vat": 4.5
1558 },
1559 "total_time": 3.2,
1560 "total_energy": 0,
1561 "last_updated": "2022-01-13T00:00:00Z"
1562 });
1563
1564 value.to_string()
1565 }
1566}