mod restriction;
mod session;
mod tariff;
mod v211;
mod v221;
use std::{borrow::Cow, fmt, ops::Range};
use chrono_tz::Tz;
use serde::Serialize;
use tracing::{debug, instrument, trace};
pub(crate) use tariff::Tariff;
pub use v221::tariff::CompatibilityVat;
use crate::{
de::obj_from_json_str, duration, warning, DateTime, HoursDecimal, Kwh, Number, ParseError,
Price, TariffId, UnexpectedFields, Version,
};
#[derive(Debug, Serialize)]
pub struct Report {
pub warnings: Vec<WarningKind>,
pub unexpected_fields: UnexpectedFields,
pub periods: Vec<Period>,
pub tariff_index: usize,
pub tariff_id: TariffId,
pub tariff_reports: Vec<(TariffId, UnexpectedFields)>,
pub timezone: String,
pub billed_energy: Kwh,
pub billed_parking_time: HoursDecimal,
pub total_charging_time: HoursDecimal,
pub billed_charging_time: HoursDecimal,
pub total_cost: Total<Price, Option<Price>>,
pub total_fixed_cost: Total<Option<Price>>,
pub total_time: Total<HoursDecimal>,
pub total_time_cost: Total<Option<Price>>,
pub total_energy: Total<Kwh>,
pub total_energy_cost: Total<Option<Price>>,
pub total_parking_time: Total<Option<HoursDecimal>, HoursDecimal>,
pub total_parking_cost: Total<Option<Price>>,
pub total_reservation_cost: Total<Option<Price>>,
}
#[derive(Debug, Serialize)]
pub enum WarningKind {
PeriodsOutsideStartEndDateTime {
cdr_range: Range<DateTime>,
period_range: PeriodRange,
},
}
impl fmt::Display for WarningKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PeriodsOutsideStartEndDateTime {
cdr_range,
period_range,
} => {
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)
}
}
}
}
impl warning::Kind for WarningKind {
fn id(&self) -> Cow<'static, str> {
match self {
WarningKind::PeriodsOutsideStartEndDateTime { .. } => {
"periods_outside_start_end_date_time".into()
}
}
}
}
#[derive(Debug, Serialize)]
pub struct Period {
pub start_date_time: DateTime,
pub end_date_time: DateTime,
pub dimensions: Dimensions,
}
impl Period {
pub fn new(period: &session::ChargePeriod, dimensions: Dimensions) -> Self {
Self {
start_date_time: period.start_instant.date_time,
end_date_time: period.end_instant.date_time,
dimensions,
}
}
pub fn cost(&self) -> Option<Price> {
[
self.dimensions.time.cost(),
self.dimensions.parking_time.cost(),
self.dimensions.flat.cost(),
self.dimensions.energy.cost(),
]
.into_iter()
.fold(None, |accum, next| {
if accum.is_none() && next.is_none() {
None
} else {
Some(
accum
.unwrap_or_default()
.saturating_add(next.unwrap_or_default()),
)
}
})
}
}
#[derive(Debug, Serialize)]
pub struct Dimensions {
pub flat: Dimension<()>,
pub energy: Dimension<Kwh>,
pub time: Dimension<HoursDecimal>,
pub parking_time: Dimension<HoursDecimal>,
}
impl Dimensions {
pub fn new(components: &tariff::PriceComponents, data: &session::PeriodData) -> Self {
Self {
parking_time: Dimension::new(components.parking, data.parking_duration.map(Into::into)),
time: Dimension::new(components.time, data.charging_duration.map(Into::into)),
energy: Dimension::new(components.energy, data.energy),
flat: Dimension::new(components.flat, Some(())),
}
}
}
#[derive(Debug, Serialize)]
pub struct Dimension<V> {
pub price: Option<tariff::PriceComponent>,
pub volume: Option<V>,
pub billed_volume: Option<V>,
}
impl<V> Dimension<V>
where
V: Copy,
{
fn new(price_component: Option<tariff::PriceComponent>, volume: Option<V>) -> Self {
Self {
price: price_component,
volume,
billed_volume: volume,
}
}
}
impl<V: tariff::Dimension> Dimension<V> {
pub fn cost(&self) -> Option<Price> {
if let (Some(volume), Some(price)) = (self.billed_volume, self.price) {
let excl_vat = volume.cost(price.price);
let incl_vat = match price.vat {
CompatibilityVat::Vat(Some(vat)) => Some(excl_vat.apply_vat(vat)),
CompatibilityVat::Vat(None) => Some(excl_vat),
CompatibilityVat::Unknown => None,
};
Some(Price { excl_vat, incl_vat })
} else {
None
}
}
}
#[derive(Debug, Serialize)]
pub struct Total<TCdr, TCalc = TCdr> {
pub cdr: TCdr,
pub calculated: TCalc,
}
#[derive(Debug)]
pub enum Error {
Deserialize(ParseError),
DimensionShouldHaveVolume {
dimension_name: &'static str,
},
DurationOverflow,
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
NoValidTariff,
Tariff(tariff::Error),
}
impl From<InvalidPeriodIndex> for Error {
fn from(err: InvalidPeriodIndex) -> Self {
Self::Internal(err.into())
}
}
#[derive(Debug)]
struct InvalidPeriodIndex(&'static str);
impl std::error::Error for InvalidPeriodIndex {}
impl fmt::Display for InvalidPeriodIndex {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Invalid index for period `{}`", self.0)
}
}
#[derive(Debug, Serialize)]
pub enum PeriodRange {
Many(Range<DateTime>),
Single(DateTime),
}
impl fmt::Display for PeriodRange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PeriodRange::Many(Range { start, end }) => write!(f, "{start}-{end}"),
PeriodRange::Single(date_time) => write!(f, "{date_time}"),
}
}
}
impl From<ParseError> for Error {
fn from(err: ParseError) -> Self {
Error::Deserialize(err)
}
}
impl From<tariff::Error> for Error {
fn from(err: tariff::Error) -> Self {
Error::Tariff(err)
}
}
impl From<duration::Error> for Error {
fn from(err: duration::Error) -> Self {
match err {
duration::Error::DurationOverflow => Self::DurationOverflow,
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Internal(err) => Some(&**err),
Error::Tariff(err) => Some(err),
_ => None,
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Deserialize(err) => {
write!(f, "{err}")
}
Self::DimensionShouldHaveVolume { dimension_name } => {
write!(f, "Dimension `{dimension_name}` should have volume")
}
Self::DurationOverflow => {
f.write_str("A numeric overflow occurred while creating a duration")
}
Self::Internal(err) => {
write!(f, "Internal: {err}")
}
Self::NoValidTariff => {
f.write_str("No valid tariff has been found in the list of provided tariffs")
}
Self::Tariff(err) => {
write!(f, "{err}")
}
}
}
}
#[derive(Debug)]
enum InternalError {
InvalidPeriodIndex {
index: usize,
field_name: &'static str,
},
}
impl std::error::Error for InternalError {}
impl From<InternalError> for Error {
fn from(err: InternalError) -> Self {
Error::Internal(Box::new(err))
}
}
impl fmt::Display for InternalError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InternalError::InvalidPeriodIndex { field_name, index } => {
write!(
f,
"Invalid period index for `{field_name}`; index: `{index}`"
)
}
}
}
}
#[derive(Debug)]
pub enum TariffSource {
UseCdr,
Override(Vec<String>),
}
#[instrument(skip_all)]
pub(crate) fn price_cdr(
cdr_json: &str,
tariff_source: TariffSource,
timezone: Tz,
version: Version,
) -> Result<Report, Error> {
let cdr_deser = cdr_from_str(cdr_json, version)?;
let DeserCdr {
mut cdr,
unexpected_fields,
} = cdr_deser;
match tariff_source {
TariffSource::UseCdr => {
debug!("Using tariffs from CDR");
let tariffs = cdr
.tariffs
.iter()
.map(|json| tariff_from_str(json.get(), version))
.collect::<Result<Vec<_>, _>>()?;
let report =
price_cdr_with_tariffs(&mut cdr, &unexpected_fields, tariffs, timezone, version)?;
Ok(report)
}
TariffSource::Override(tariffs) => {
debug!("Using override tariffs");
let tariffs = tariffs
.iter()
.map(|json| tariff_from_str(json, version))
.collect::<Result<Vec<_>, _>>()?;
let report =
price_cdr_with_tariffs(&mut cdr, &unexpected_fields, tariffs, timezone, version)?;
Ok(report)
}
}
}
fn price_cdr_with_tariffs<'a>(
cdr: &mut v221::Cdr<'a>,
unexpected_fields: &UnexpectedFields,
tariffs: Vec<DeserTariff<'a>>,
timezone: Tz,
version: Version,
) -> Result<Report, Error> {
debug!(?timezone, ?version, "Pricing CDR");
let warnings = validate_and_sanitize_cdr(cdr);
let tariff_reports = process_tariffs(tariffs)?;
debug!(tariffs = ?tariff_reports.iter().map(|report| report.tariff.id()).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
let tariff = find_first_active_tariff(&tariff_reports, cdr.start_date_time)
.ok_or(Error::NoValidTariff)?;
let (tariff_index, tariff) = tariff;
debug!(
id = tariff.id(),
index = tariff_index,
"Found active tariff"
);
debug!(%timezone, "Found timezone");
debug!("Extracting charge periods");
let cs_periods = session::extract_periods(cdr, timezone)?;
debug!(count = cs_periods.len(), "Found CDR periods");
trace!("# CDR period list:");
for period in &cs_periods {
trace!("{period:#?}");
}
let mut periods = Vec::new();
let mut step_size = StepSize::new();
let mut total_energy = Kwh::zero();
let mut total_charging_time = HoursDecimal::zero();
let mut total_parking_time = HoursDecimal::zero();
let mut has_flat_fee = false;
debug!(
tariff_id = tariff.id(),
period_count = periods.len(),
"Accumulating `total_charging_time`, `total_energy` and `total_parking_time`"
);
for (index, period) in cs_periods.iter().enumerate() {
let mut components = tariff.active_components(period);
trace!(
index,
"Creating charge period with Dimension\n{period:#?}\n{components:#?}"
);
if components.flat.is_some() {
if has_flat_fee {
components.flat = None;
} else {
has_flat_fee = true;
}
}
step_size.update(index, &components, period);
trace!(period_index = index, "Step size updated\n{step_size:#?}");
let dimensions = Dimensions::new(&components, &period.period_data);
trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
total_charging_time = total_charging_time
.saturating_add(dimensions.time.volume.unwrap_or_else(HoursDecimal::zero));
total_energy =
total_energy.saturating_add(dimensions.energy.volume.unwrap_or_else(Kwh::zero));
total_parking_time = total_parking_time.saturating_add(
dimensions
.parking_time
.volume
.unwrap_or_else(HoursDecimal::zero),
);
trace!(period_index = index, "Update totals");
trace!("total_charging_time: {total_charging_time:#?}");
trace!("total_energy: {total_energy:#?}");
trace!("total_parking_time: {total_parking_time:#?}");
periods.push(Period::new(period, dimensions));
}
let billed_charging_time = step_size.apply_time(&mut periods, total_charging_time)?;
let billed_energy = step_size.apply_energy(&mut periods, total_energy)?;
let billed_parking_time = step_size.apply_parking_time(&mut periods, total_parking_time)?;
trace!("Update billed totals");
trace!("billed_charging_time: {billed_charging_time:#?}");
trace!("billed_energy: {billed_energy:#?}");
trace!("billed_parking_time: {billed_parking_time:#?}");
let mut total_energy_cost: Option<Price> = None;
let mut total_fixed_cost: Option<Price> = None;
let mut total_parking_cost: Option<Price> = None;
let mut total_time_cost: Option<Price> = None;
debug!(
tariff_id = tariff.id(),
period_count = periods.len(),
"Accumulating `total_energy_cost`, `total_fixed_cost`, `total_parking_cost` and `total_time_cost`"
);
for (index, period) in periods.iter().enumerate() {
let dimensions = &period.dimensions;
trace!(period_index = index, "Processing period");
total_energy_cost = match (total_energy_cost, dimensions.energy.cost()) {
(None, None) => None,
(total, period) => Some(
total
.unwrap_or_default()
.saturating_add(period.unwrap_or_default()),
),
};
total_time_cost = match (total_time_cost, dimensions.time.cost()) {
(None, None) => None,
(total, period) => Some(
total
.unwrap_or_default()
.saturating_add(period.unwrap_or_default()),
),
};
total_parking_cost = match (total_parking_cost, dimensions.parking_time.cost()) {
(None, None) => None,
(total, period) => Some(
total
.unwrap_or_default()
.saturating_add(period.unwrap_or_default()),
),
};
total_fixed_cost = match (total_fixed_cost, dimensions.flat.cost()) {
(None, None) => None,
(total, period) => Some(
total
.unwrap_or_default()
.saturating_add(period.unwrap_or_default()),
),
};
trace!(period_index = index, "Update totals");
trace!("total_energy_cost = {total_energy_cost:?}");
trace!("total_fixed_cost = {total_fixed_cost:?}");
trace!("total_parking_cost = {total_parking_cost:?}");
trace!("total_time_cost = {total_time_cost:?}");
}
trace!("Calculating `total_cost` by accumulating `total_energy_cost`, `total_fixed_cost`, `total_parking_cost` and `total_time_cost`");
trace!("total_energy_cost = {total_energy_cost:?}");
trace!("total_fixed_cost = {total_fixed_cost:?}");
trace!("total_parking_cost = {total_parking_cost:?}");
trace!("total_time_cost = {total_time_cost:?}");
debug!(
?total_energy_cost,
?total_fixed_cost,
?total_parking_cost,
?total_time_cost,
"Calculating `total_cost`"
);
let total_cost = [
total_energy_cost,
total_fixed_cost,
total_parking_cost,
total_time_cost,
]
.into_iter()
.fold(None, |accum: Option<Price>, next| match (accum, next) {
(None, None) => None,
_ => Some(
accum
.unwrap_or_default()
.saturating_add(next.unwrap_or_default()),
),
});
debug!(?total_cost);
let total_time = {
debug!(
period_start = ?periods.first().map(|p| p.start_date_time),
period_end = ?periods.last().map(|p| p.end_date_time),
"Calculating `total_time`"
);
if let Some((first, last)) = periods.first().zip(periods.last()) {
let time_delta = last
.end_date_time
.signed_duration_since(*first.start_date_time);
time_delta.into()
} else {
HoursDecimal::zero()
}
};
debug!(%total_time);
let report = Report {
periods,
tariff_index,
tariff_id: tariff.id().to_string(),
timezone: timezone.to_string(),
billed_parking_time,
billed_energy,
billed_charging_time,
unexpected_fields: unexpected_fields.clone(),
tariff_reports: tariff_reports
.into_iter()
.map(
|TariffReport {
tariff,
unexpected_fields,
}| (tariff.id().to_string(), unexpected_fields),
)
.collect(),
total_charging_time,
total_cost: Total {
cdr: cdr.total_cost,
calculated: total_cost,
},
total_time_cost: Total {
cdr: cdr.total_time_cost,
calculated: total_time_cost,
},
total_time: Total {
cdr: cdr.total_time,
calculated: total_time,
},
total_parking_cost: Total {
cdr: cdr.total_parking_cost,
calculated: total_parking_cost,
},
total_parking_time: Total {
cdr: cdr.total_parking_time,
calculated: total_parking_time,
},
total_energy_cost: Total {
cdr: cdr.total_energy_cost,
calculated: total_energy_cost,
},
total_energy: Total {
cdr: cdr.total_energy,
calculated: total_energy,
},
total_fixed_cost: Total {
cdr: cdr.total_fixed_cost,
calculated: total_fixed_cost,
},
total_reservation_cost: Total {
cdr: cdr.total_reservation_cost,
calculated: None,
},
warnings: warnings.into_kind_vec(),
};
trace!("{report:#?}");
Ok(report)
}
fn validate_and_sanitize_cdr(cdr: &mut v221::Cdr<'_>) -> warning::Set<WarningKind> {
let mut warnings = warning::Set::new();
let cdr_range = cdr.start_date_time..cdr.end_date_time;
cdr.charging_periods.sort_by_key(|p| p.start_date_time);
match cdr.charging_periods.as_slice() {
[] => (),
[period] => {
if !cdr_range.contains(&period.start_date_time) {
warnings.only_kind(WarningKind::PeriodsOutsideStartEndDateTime {
cdr_range,
period_range: PeriodRange::Single(period.start_date_time),
});
}
}
[period_earliest, .., period_latest] => {
let period_range = period_earliest.start_date_time..period_latest.start_date_time;
if !(cdr_range.contains(&period_range.start) && cdr_range.contains(&period_range.end)) {
warnings.only_kind(WarningKind::PeriodsOutsideStartEndDateTime {
cdr_range,
period_range: PeriodRange::Many(period_range),
});
}
}
}
warnings
}
struct TariffReport<'a> {
tariff: Tariff<'a>,
unexpected_fields: UnexpectedFields,
}
fn process_tariffs(deser_tariffs: Vec<DeserTariff<'_>>) -> Result<Vec<TariffReport<'_>>, Error> {
let mut tariff_reports = vec![];
for tariff in deser_tariffs {
let DeserTariff {
tariff,
unexpected_fields,
} = tariff;
let tariff = Tariff::new(&tariff)?;
tariff_reports.push(TariffReport {
tariff,
unexpected_fields,
});
}
Ok(tariff_reports)
}
fn find_first_active_tariff<'a>(
tariffs: &'a [TariffReport<'a>],
start_date_time: DateTime,
) -> Option<(usize, &'a Tariff<'a>)> {
let tariffs: Vec<_> = tariffs.iter().map(|report| &report.tariff).collect();
tariffs
.into_iter()
.enumerate()
.find(|(_, t)| t.is_active(start_date_time))
}
#[derive(Debug)]
struct StepSize {
time: Option<(usize, tariff::PriceComponent)>,
parking_time: Option<(usize, tariff::PriceComponent)>,
energy: Option<(usize, tariff::PriceComponent)>,
}
impl StepSize {
fn new() -> Self {
Self {
time: None,
parking_time: None,
energy: None,
}
}
fn update(
&mut self,
index: usize,
components: &tariff::PriceComponents,
period: &session::ChargePeriod,
) {
if period.period_data.energy.is_some() {
if let Some(energy) = components.energy {
self.energy = Some((index, energy));
}
}
if period.period_data.charging_duration.is_some() {
if let Some(time) = components.time {
self.time = Some((index, time));
}
}
if period.period_data.parking_duration.is_some() {
if let Some(parking) = components.parking {
self.parking_time = Some((index, parking));
}
}
}
fn duration_step_size(
total_volume: HoursDecimal,
period_billed_volume: &mut HoursDecimal,
step_size: u64,
) -> Result<HoursDecimal, Error> {
if step_size == 0 {
return Ok(total_volume);
}
let total_seconds = total_volume.as_num_seconds_number();
let step_size = Number::from(step_size);
let total_billed_volume = HoursDecimal::from_seconds_number(
total_seconds
.checked_div(step_size)
.ok_or(Error::DurationOverflow)?
.ceil()
.saturating_mul(step_size),
)?;
let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
*period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
Ok(total_billed_volume)
}
fn apply_time(
&self,
periods: &mut [Period],
total: HoursDecimal,
) -> Result<HoursDecimal, Error> {
let (Some((time_index, price)), None) = (&self.time, &self.parking_time) else {
return Ok(total);
};
let Some(period) = periods.get_mut(*time_index) else {
return Err(InternalError::InvalidPeriodIndex {
index: *time_index,
field_name: "apply_time",
}
.into());
};
let volume = period.dimensions.time.billed_volume.as_mut().ok_or(
Error::DimensionShouldHaveVolume {
dimension_name: "time",
},
)?;
Self::duration_step_size(total, volume, price.step_size)
}
fn apply_parking_time(
&self,
periods: &mut [Period],
total: HoursDecimal,
) -> Result<HoursDecimal, Error> {
let Some((parking_index, price)) = &self.parking_time else {
return Ok(total);
};
let Some(period) = periods.get_mut(*parking_index) else {
return Err(InternalError::InvalidPeriodIndex {
index: *parking_index,
field_name: "apply_parking_time",
}
.into());
};
let volume = period
.dimensions
.parking_time
.billed_volume
.as_mut()
.ok_or(Error::DimensionShouldHaveVolume {
dimension_name: "parking_time",
})?;
Self::duration_step_size(total, volume, price.step_size)
}
fn apply_energy(&self, periods: &mut [Period], total_volume: Kwh) -> Result<Kwh, Error> {
let Some((energy_index, price)) = &self.energy else {
return Ok(total_volume);
};
if price.step_size == 0 {
return Ok(total_volume);
}
let Some(period) = periods.get_mut(*energy_index) else {
return Err(InternalError::InvalidPeriodIndex {
index: *energy_index,
field_name: "apply_energy",
}
.into());
};
let step_size = Number::from(price.step_size);
let period_billed_volume = period.dimensions.energy.billed_volume.as_mut().ok_or(
Error::DimensionShouldHaveVolume {
dimension_name: "energy",
},
)?;
let total_billed_volume = Kwh::from_watt_hours(
total_volume
.watt_hours()
.checked_div(step_size)
.ok_or(Error::DurationOverflow)?
.ceil()
.saturating_mul(step_size),
);
let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
*period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
Ok(total_billed_volume)
}
}
#[derive(Debug)]
struct DeserCdr<'a> {
cdr: v221::Cdr<'a>,
unexpected_fields: UnexpectedFields,
}
fn cdr_from_str<'a>(json: &'a str, version: Version) -> Result<DeserCdr<'a>, ParseError> {
match version {
Version::V221 => {
let (cdr, unexpected_fields) =
obj_from_json_str::<v221::Cdr<'a>>(json).map_err(ParseError::from_cdr_serde_err)?;
Ok(DeserCdr {
cdr,
unexpected_fields,
})
}
Version::V211 => {
let (cdr, unexpected_fields) =
obj_from_json_str::<v211::Cdr<'_>>(json).map_err(ParseError::from_cdr_serde_err)?;
Ok(DeserCdr {
cdr: cdr.into(),
unexpected_fields,
})
}
}
}
#[derive(Debug)]
struct DeserTariff<'a> {
tariff: v221::Tariff<'a>,
unexpected_fields: UnexpectedFields,
}
fn tariff_from_str<'a>(json: &'a str, version: Version) -> Result<DeserTariff<'a>, ParseError> {
match version {
Version::V221 => {
let (tariff, unexpected_fields) = obj_from_json_str::<v221::Tariff<'a>>(json)
.map_err(ParseError::from_tariff_serde_err)?;
Ok(DeserTariff {
tariff,
unexpected_fields,
})
}
Version::V211 => {
let (tariff, unexpected_fields) = obj_from_json_str::<v211::Tariff<'a>>(json)
.map_err(ParseError::from_tariff_serde_err)?;
Ok(DeserTariff {
tariff: tariff.into(),
unexpected_fields,
})
}
}
}
#[cfg(test)]
pub mod test {
#![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
#![allow(clippy::panic, reason = "tests are allowed panic")]
use std::collections::BTreeMap;
use tracing::debug;
use crate::{
json,
price::Total,
test::{DecimalPartialEq, Expectation},
timezone,
warning::Kind,
HoursDecimal, Kwh, Price,
};
use super::{Error, Report};
const PRECISION: u32 = 2;
#[test]
const fn error_should_be_send_and_sync() {
const fn f<T: Send + Sync>() {}
f::<Error>();
}
#[track_caller]
pub fn parse_expect_json(expect_json: Option<&str>) -> Expect {
expect_json
.map(|json| serde_json::from_str(json).expect("Unable to parse expect JSON"))
.unwrap_or_default()
}
#[derive(serde::Deserialize, Default)]
pub struct Expect {
pub timezone_find: Option<timezone::test::FindOrInferExpect>,
pub cdr_parse: Option<ParseExpect>,
pub cdr_price: Option<PriceExpect>,
}
pub(crate) fn assert_parse_report(
unexpected_fields: json::UnexpectedFields<'_>,
cdr_price_expect: Option<ParseExpect>,
) {
let unexpected_fields_expect = cdr_price_expect
.map(|exp| {
let ParseExpect { unexpected_fields } = exp;
unexpected_fields
})
.unwrap_or(Expectation::Absent);
if let Expectation::Present(expectation) = unexpected_fields_expect {
let unexpected_fields_expect = expectation.expect_value();
for field in unexpected_fields {
assert!(
unexpected_fields_expect.contains(&field.to_string()),
"The CDR has an unexpected field that's not expected: `{field}`"
);
}
} else {
assert!(
unexpected_fields.is_empty(),
"The CDR has unexpected fields; {unexpected_fields:#}",
);
}
}
pub(crate) fn assert_price_report(report: Report, cdr_price_expect: Option<PriceExpect>) {
let Report {
warnings,
unexpected_fields,
tariff_reports,
periods: _,
tariff_index,
tariff_id,
timezone: _,
billed_energy: _,
billed_parking_time: _,
billed_charging_time: _,
total_charging_time: _,
total_cost,
total_fixed_cost,
total_time,
total_time_cost,
total_energy,
total_energy_cost,
total_parking_time,
total_parking_cost,
total_reservation_cost,
} = report;
let (
warnings_expect,
unexpected_fields_expect,
tariff_index_expect,
tariff_id_expect,
tariff_reports_expect,
total_cost_expectation,
total_fixed_cost_expectation,
total_time_expectation,
total_time_cost_expectation,
total_energy_expectation,
total_energy_cost_expectation,
total_parking_time_expectation,
total_parking_cost_expectation,
total_reservation_cost_expectation,
) = cdr_price_expect
.map(|exp| {
let PriceExpect {
warnings,
unexpected_fields,
tariff_index,
tariff_id,
tariff_reports,
total_cost,
total_fixed_cost,
total_time,
total_time_cost,
total_energy,
total_energy_cost,
total_parking_time,
total_parking_cost,
total_reservation_cost,
} = exp;
(
warnings,
unexpected_fields,
tariff_index,
tariff_id,
tariff_reports,
total_cost,
total_fixed_cost,
total_time,
total_time_cost,
total_energy,
total_energy_cost,
total_parking_time,
total_parking_cost,
total_reservation_cost,
)
})
.unwrap_or((
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
));
if let Expectation::Present(expectation) = warnings_expect {
let warnings_expect = expectation.expect_value();
debug!("{warnings_expect:?}");
for warning in warnings {
assert!(
warnings_expect.contains(&warning.id().to_string()),
"The CDR has a warning that's not expected"
);
}
} else {
assert!(warnings.is_empty(), "The CDR has warnings; {warnings:?}",);
}
if let Expectation::Present(expectation) = unexpected_fields_expect {
let unexpected_fields_expect = expectation.expect_value();
for field in unexpected_fields {
assert!(
unexpected_fields_expect.contains(&field),
"The CDR has an unexpected field that's not expected: `{field}`"
);
}
} else {
assert!(
unexpected_fields.is_empty(),
"The CDR has unexpected fields; {unexpected_fields:?}",
);
}
if let Expectation::Present(expectation) = tariff_reports_expect {
let tariff_reports_expect: BTreeMap<_, _> = expectation
.expect_value()
.into_iter()
.map(
|TariffReport {
id,
unexpected_fields,
}| (id, unexpected_fields),
)
.collect();
for (tariff_id, mut unexpected_fields) in tariff_reports {
let Some(unexpected_fields_expect) = tariff_reports_expect.get(&*tariff_id) else {
panic!("A tariff with {tariff_id} is not expected");
};
debug!("{:?}", unexpected_fields_expect);
unexpected_fields.retain(|field| {
let present = unexpected_fields_expect.contains(field);
assert!(present, "The tariff with id: `{tariff_id}` has an unexpected field that is not expected: `{field}`");
!present
});
assert!(
unexpected_fields.is_empty(),
"The tariff with id `{tariff_id}` has unexpected fields; {unexpected_fields:?}",
);
}
} else {
for (id, unexpected_fields) in tariff_reports {
assert!(
unexpected_fields.is_empty(),
"The tariff with id `{id}` has unexpected fields; {unexpected_fields:?}",
);
}
}
if let Expectation::Present(expectation) = tariff_id_expect {
assert_eq!(tariff_id, expectation.expect_value());
}
if let Expectation::Present(expectation) = tariff_index_expect {
assert_eq!(tariff_index, expectation.expect_value());
}
total_cost_expectation.expect_price("total_cost", &total_cost);
total_fixed_cost_expectation.expect_opt_price("total_fixed_cost", &total_fixed_cost);
total_time_expectation.expect_duration("total_time", &total_time);
total_time_cost_expectation.expect_opt_price("total_time_cost", &total_time_cost);
total_energy_expectation.expect_kwh("total_energy", &total_energy);
total_energy_cost_expectation.expect_opt_price("total_energy_cost", &total_energy_cost);
total_parking_time_expectation
.expect_opt_duration("total_parking_time", &total_parking_time);
total_parking_cost_expectation.expect_opt_price("total_parking_cost", &total_parking_cost);
total_reservation_cost_expectation
.expect_opt_price("total_reservation_cost", &total_reservation_cost);
}
#[derive(serde::Deserialize)]
pub struct ParseExpect {
#[serde(default)]
unexpected_fields: Expectation<Vec<String>>,
}
#[derive(serde::Deserialize)]
pub struct PriceExpect {
#[serde(default)]
warnings: Expectation<Vec<String>>,
#[serde(default)]
unexpected_fields: Expectation<Vec<String>>,
#[serde(default)]
tariff_index: Expectation<usize>,
#[serde(default)]
tariff_id: Expectation<String>,
#[serde(default)]
tariff_reports: Expectation<Vec<TariffReport>>,
#[serde(default)]
total_cost: Expectation<Price>,
#[serde(default)]
total_fixed_cost: Expectation<Price>,
#[serde(default)]
total_time: Expectation<HoursDecimal>,
#[serde(default)]
total_time_cost: Expectation<Price>,
#[serde(default)]
total_energy: Expectation<Kwh>,
#[serde(default)]
total_energy_cost: Expectation<Price>,
#[serde(default)]
total_parking_time: Expectation<HoursDecimal>,
#[serde(default)]
total_parking_cost: Expectation<Price>,
#[serde(default)]
total_reservation_cost: Expectation<Price>,
}
#[derive(Debug, serde::Deserialize)]
pub struct TariffReport {
id: String,
unexpected_fields: Vec<String>,
}
impl Expectation<Price> {
#[track_caller]
fn expect_opt_price(self, field_name: &str, total: &Total<Option<Price>>) {
if let Expectation::Present(expect_value) = self {
match (expect_value.into_option(), total.calculated) {
(Some(a), Some(b)) => assert!(
a.eq_dec(&b),
"Expected `{a}` but `{b}` was calculated for `{field_name}`"
),
(Some(a), None) => {
panic!("Expected `{a}`, but no price was calculated for `{field_name}`")
}
(None, Some(b)) => {
panic!("Expected no value, but `{b}` was calculated for `{field_name}`")
}
(None, None) => (),
}
} else {
match (total.cdr, total.calculated) {
(None, None) => (),
(None, Some(calculated)) => {
assert!(calculated.is_zero(), "The CDR field `{field_name}` doesn't have a value but a value was calculated; calculated: {calculated}");
}
(Some(cdr), None) => {
assert!(
cdr.is_zero(),
"The CDR field `{field_name}` has a value but the calculated value is none; cdr: {cdr}"
);
}
(Some(cdr), Some(calculated)) => {
assert!(
cdr.eq_dec(&calculated),
"Comparing `{field_name}` field with CDR"
);
}
}
}
}
#[track_caller]
fn expect_price(self, field_name: &str, total: &Total<Price, Option<Price>>) {
if let Expectation::Present(expect_value) = self {
match (expect_value.into_option(), total.calculated) {
(Some(a), Some(b)) => assert!(
a.eq_dec(&b),
"Expected `{a}` but `{b}` was calculated for `{field_name}`"
),
(Some(a), None) => {
panic!("Expected `{a}`, but no price was calculated for `{field_name}`")
}
(None, Some(b)) => {
panic!("Expected no value, but `{b}` was calculated for `{field_name}`")
}
(None, None) => (),
}
} else if let Some(calculated) = total.calculated {
assert!(
total.cdr.eq_dec(&calculated),
"CDR contains `{}` but `{}` was calculated for `{field_name}`",
total.cdr,
calculated
);
} else {
assert!(
total.cdr.is_zero(),
"The CDR field `{field_name}` has a value but the calculated value is none; cdr: {:?}",
total.cdr
);
}
}
}
impl Expectation<HoursDecimal> {
#[track_caller]
fn expect_duration(self, field_name: &str, total: &Total<HoursDecimal>) {
if let Expectation::Present(expect_value) = self {
assert_eq!(
expect_value.expect_value().as_num_hours_number(),
total.calculated.as_num_hours_number().round_dp(PRECISION),
"Comparing `{field_name}` field with expectation"
);
} else {
assert_eq!(
total.cdr.as_num_hours_number().round_dp(PRECISION),
total.calculated.as_num_hours_number().round_dp(PRECISION),
"Comparing `{field_name}` field with CDR"
);
}
}
#[track_caller]
fn expect_opt_duration(
self,
field_name: &str,
total: &Total<Option<HoursDecimal>, HoursDecimal>,
) {
if let Expectation::Present(expect_value) = self {
assert_eq!(
expect_value.expect_value().as_num_hours_number(),
total.calculated.as_num_hours_number().round_dp(PRECISION),
"Comparing `{field_name}` field with expectation"
);
} else {
assert_eq!(
total
.cdr
.unwrap_or_default()
.as_num_hours_number()
.round_dp(PRECISION),
total.calculated.as_num_hours_number().round_dp(PRECISION),
"Comparing `{field_name}` field with CDR"
);
}
}
}
impl Expectation<Kwh> {
#[track_caller]
fn expect_kwh(self, field_name: &str, total: &Total<Kwh>) {
if let Expectation::Present(expect_value) = self {
assert_eq!(
expect_value.expect_value().round_dp(PRECISION),
total.calculated.rescale().round_dp(PRECISION),
"Comparing `{field_name}` field with expectation"
);
} else {
assert_eq!(
total.cdr.round_dp(PRECISION),
total.calculated.rescale().round_dp(PRECISION),
"Comparing `{field_name}` field with CDR"
);
}
}
}
}
#[cfg(test)]
mod test_validate_cdr {
use assert_matches::assert_matches;
use crate::{
de::obj_from_json_str,
price::{self, v221, WarningKind},
test::{self, datetime_from_str},
};
use super::validate_and_sanitize_cdr;
#[test]
fn should_pass_validation() {
test::setup();
let json = cdr_json("2022-01-13T16:00:00Z", "2022-01-13T19:12:00Z");
let (mut cdr, _) = obj_from_json_str::<v221::Cdr<'_>>(&json).unwrap();
let warnings = validate_and_sanitize_cdr(&mut cdr);
assert!(warnings.is_empty());
}
#[test]
fn should_fail_validation_start_end_range_doesnt_overlap_with_periods() {
test::setup();
let json = cdr_json("2022-02-13T16:00:00Z", "2022-02-13T19:12:00Z");
let (mut cdr, _) = obj_from_json_str::<v221::Cdr<'_>>(&json).unwrap();
let warnings = validate_and_sanitize_cdr(&mut cdr).into_kind_vec();
let [warning] = warnings.try_into().unwrap();
let (cdr_range, period_range) = assert_matches!(warning, WarningKind::PeriodsOutsideStartEndDateTime { cdr_range, period_range } => (cdr_range, period_range));
{
assert_eq!(cdr_range.start, datetime_from_str("2022-02-13T16:00:00Z"));
assert_eq!(cdr_range.end, datetime_from_str("2022-02-13T19:12:00Z"));
}
{
let period_range =
assert_matches!(period_range, price::PeriodRange::Many(range) => range);
assert_eq!(
period_range.start,
datetime_from_str("2022-01-13T16:00:00Z")
);
assert_eq!(period_range.end, datetime_from_str("2022-01-13T18:30:00Z"));
}
}
fn cdr_json(start_date_time: &str, end_date_time: &str) -> String {
let value = serde_json::json!({
"start_date_time": start_date_time,
"end_date_time": end_date_time,
"currency": "EUR",
"tariffs": [],
"cdr_location": {
"country": "NLD"
},
"charging_periods": [
{
"start_date_time": "2022-01-13T16:00:00Z",
"dimensions": [
{
"type": "TIME",
"volume": 2.5
}
]
},
{
"start_date_time": "2022-01-13T18:30:00Z",
"dimensions": [
{
"type": "PARKING_TIME",
"volume": 0.7
}
]
}
],
"total_cost": {
"excl_vat": 11.25,
"incl_vat": 12.75
},
"total_time_cost": {
"excl_vat": 7.5,
"incl_vat": 8.25
},
"total_parking_time": 0.7,
"total_parking_cost": {
"excl_vat": 3.75,
"incl_vat": 4.5
},
"total_time": 3.2,
"total_energy": 0,
"last_updated": "2022-01-13T00:00:00Z"
});
value.to_string()
}
}