#[cfg(test)]
pub mod test;
#[cfg(test)]
mod test_normalize_periods;
#[cfg(test)]
mod test_periods;
#[cfg(test)]
mod test_real_world;
#[cfg(test)]
mod test_validate_cdr;
mod tariff;
mod v211;
mod v221;
use std::{borrow::Cow, collections::BTreeMap, fmt, ops::Range};
use chrono::{DateTime, Datelike, TimeDelta, Utc};
use chrono_tz::Tz;
use rust_decimal::Decimal;
use tracing::{debug, error, instrument, trace};
use crate::{
country, currency, datetime,
duration::{self, AsHms, Hms},
enumeration, from_warning_all,
json::{self, FromJson as _},
money,
number::{self, RoundDecimal},
string,
warning::{
self, GatherDeferredWarnings as _, GatherWarnings as _, IntoCaveat,
IntoCaveatDeferred as _, VerdictExt as _, WithElement as _,
},
Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, ParseError, Price, SaturatingAdd as _,
SaturatingSub as _, VatApplicable, Version, Versioned as _,
};
use tariff::Tariff;
type Verdict<T> = crate::Verdict<T, Warning>;
type VerdictDeferred<T> = warning::VerdictDeferred<T, Warning>;
#[derive(Debug)]
struct PeriodNormalized {
consumed: Consumed,
start_snapshot: TotalsSnapshot,
end_snapshot: TotalsSnapshot,
}
#[derive(Clone)]
#[cfg_attr(test, derive(Default))]
pub(crate) struct Consumed {
pub current_max: Option<Ampere>,
pub current_min: Option<Ampere>,
pub duration_charging: Option<TimeDelta>,
pub duration_parking: Option<TimeDelta>,
pub energy: Option<Kwh>,
pub power_max: Option<Kw>,
pub power_min: Option<Kw>,
}
impl fmt::Debug for Consumed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Consumed")
.field("current_max", &self.current_max)
.field("current_min", &self.current_min)
.field(
"duration_charging",
&self.duration_charging.map(|dt| dt.as_hms()),
)
.field(
"duration_parking",
&self.duration_parking.map(|dt| dt.as_hms()),
)
.field("energy", &self.energy)
.field("power_max", &self.power_max)
.field("power_min", &self.power_min)
.finish()
}
}
#[derive(Clone)]
struct TotalsSnapshot {
date_time: DateTime<Utc>,
energy: Kwh,
local_timezone: Tz,
duration_charging: TimeDelta,
duration_total: TimeDelta,
}
impl fmt::Debug for TotalsSnapshot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TotalsSnapshot")
.field("date_time", &self.date_time)
.field("energy", &self.energy)
.field("local_timezone", &self.local_timezone)
.field("duration_charging", &self.duration_charging.as_hms())
.field("duration_total", &self.duration_total.as_hms())
.finish()
}
}
impl TotalsSnapshot {
fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
Self {
date_time,
energy: Kwh::zero(),
local_timezone,
duration_charging: TimeDelta::zero(),
duration_total: TimeDelta::zero(),
}
}
fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
let duration = date_time.signed_duration_since(self.date_time);
let mut next = Self {
date_time,
energy: self.energy,
local_timezone: self.local_timezone,
duration_charging: self.duration_charging,
duration_total: self.duration_total.saturating_add(duration),
};
if let Some(duration) = consumed.duration_charging {
next.duration_charging = next.duration_charging.saturating_add(duration);
}
if let Some(energy) = consumed.energy {
next.energy = next.energy.saturating_add(energy);
}
next
}
fn local_time(&self) -> chrono::NaiveTime {
self.date_time.with_timezone(&self.local_timezone).time()
}
fn local_date(&self) -> chrono::NaiveDate {
self.date_time
.with_timezone(&self.local_timezone)
.date_naive()
}
fn local_weekday(&self) -> chrono::Weekday {
self.date_time.with_timezone(&self.local_timezone).weekday()
}
}
pub struct Report {
pub periods: Vec<PeriodReport>,
pub tariff_used: TariffOrigin,
pub tariff_reports: Vec<TariffReport>,
pub timezone: String,
pub billed_charging_time: Option<TimeDelta>,
pub billed_energy: Option<Kwh>,
pub billed_parking_time: Option<TimeDelta>,
pub total_charging_time: Option<TimeDelta>,
pub total_energy: Total<Kwh, Option<Kwh>>,
pub total_parking_time: Total<Option<TimeDelta>>,
pub total_time: Total<TimeDelta>,
pub total_cost: Total<Price, Option<Price>>,
pub total_energy_cost: Total<Option<Price>>,
pub total_fixed_cost: Total<Option<Price>>,
pub total_parking_cost: Total<Option<Price>>,
pub total_reservation_cost: Total<Option<Price>>,
pub total_time_cost: Total<Option<Price>>,
}
impl fmt::Debug for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Report")
.field("periods", &self.periods)
.field("tariff_used", &self.tariff_used)
.field("tariff_reports", &self.tariff_reports)
.field("timezone", &self.timezone)
.field(
"billed_charging_time",
&self.billed_charging_time.map(|dt| dt.as_hms()),
)
.field("billed_energy", &self.billed_energy)
.field(
"billed_parking_time",
&self.billed_parking_time.map(|dt| dt.as_hms()),
)
.field(
"total_charging_time",
&self.total_charging_time.map(|dt| dt.as_hms()),
)
.field("total_energy", &self.total_energy)
.field("total_parking_time", &self.total_parking_time)
.field("total_time", &self.total_time)
.field("total_cost", &self.total_cost)
.field("total_energy_cost", &self.total_energy_cost)
.field("total_fixed_cost", &self.total_fixed_cost)
.field("total_parking_cost", &self.total_parking_cost)
.field("total_reservation_cost", &self.total_reservation_cost)
.field("total_time_cost", &self.total_time_cost)
.finish()
}
}
#[derive(Debug)]
pub enum Warning {
Country(country::Warning),
Currency(currency::Warning),
DateTime(datetime::Warning),
Decode(json::decode::Warning),
Duration(duration::Warning),
Enum(enumeration::Warning),
CountryShouldBeAlpha2,
DimensionShouldHaveVolume {
dimension_name: &'static str,
},
FieldInvalidType {
expected_type: json::ValueKind,
},
FieldInvalidValue {
value: String,
message: Cow<'static, str>,
},
FieldRequired {
field_name: Cow<'static, str>,
},
InternalError,
Money(money::Warning),
NoPeriods,
NoValidTariff,
Number(number::Warning),
Parse(ParseError),
PeriodsOutsideStartEndDateTime {
cdr_range: Range<DateTime<Utc>>,
period_range: PeriodRange,
},
String(string::Warning),
Tariff(crate::tariff::Warning),
}
impl Warning {
fn field_invalid_value(
value: impl Into<String>,
message: impl Into<Cow<'static, str>>,
) -> Self {
Warning::FieldInvalidValue {
value: value.into(),
message: message.into(),
}
}
}
impl fmt::Display for Warning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Country(warning) => write!(f, "{warning}"),
Self::CountryShouldBeAlpha2 => {
f.write_str("The `$.country` field should be an alpha-2 country code.")
}
Self::Currency(warning) => write!(f, "{warning}"),
Self::DateTime(warning) => write!(f, "{warning}"),
Self::Decode(warning) => write!(f, "{warning}"),
Self::DimensionShouldHaveVolume { dimension_name } => {
write!(f, "Dimension `{dimension_name}` should have volume")
}
Self::Duration(warning) => write!(f, "{warning}"),
Self::Enum(warning) => write!(f, "{warning}"),
Self::FieldInvalidType { expected_type } => {
write!(f, "Field has invalid type. Expected type `{expected_type}`")
}
Self::FieldInvalidValue { value, message } => {
write!(f, "Field has invalid value `{value}`: {message}")
}
Self::FieldRequired { field_name } => {
write!(f, "Field is required: `{field_name}`")
}
Self::InternalError => f.write_str("Internal error"),
Self::Money(warning) => write!(f, "{warning}"),
Self::NoPeriods => f.write_str("The CDR has no charging periods"),
Self::NoValidTariff => {
f.write_str("No valid tariff has been found in the list of provided tariffs")
}
Self::Number(warning) => write!(f, "{warning}"),
Self::Parse(err) => {
write!(f, "{err}")
}
Self::PeriodsOutsideStartEndDateTime {
cdr_range: Range { start, end },
period_range,
} => {
write!(
f,
"The CDR's charging period time range is not contained within the `start_date_time` \
and `end_date_time`; cdr: [start: {start}, end: {end}], period: {period_range}",
)
}
Self::String(warning) => write!(f, "{warning}"),
Self::Tariff(warnings) => {
write!(f, "Tariff warnings: {warnings:?}")
}
}
}
}
impl crate::Warning for Warning {
fn id(&self) -> warning::Id {
match self {
Self::Country(warning) => warning.id(),
Self::CountryShouldBeAlpha2 => warning::Id::from_static("country_should_be_alpha_2"),
Self::Currency(warning) => warning.id(),
Self::DateTime(warning) => warning.id(),
Self::Decode(warning) => warning.id(),
Self::DimensionShouldHaveVolume { dimension_name } => {
warning::Id::from_string(format!("dimension_should_have_volume({dimension_name})"))
}
Self::Duration(warning) => warning.id(),
Self::Enum(warning) => warning.id(),
Self::FieldInvalidType { expected_type } => {
warning::Id::from_string(format!("field_invalid_type({expected_type})"))
}
Self::FieldInvalidValue { value, .. } => {
warning::Id::from_string(format!("field_invalid_value({value})"))
}
Self::FieldRequired { field_name } => {
warning::Id::from_string(format!("field_required({field_name})"))
}
Self::InternalError => warning::Id::from_static("internal_error"),
Self::Money(warning) => warning.id(),
Self::NoPeriods => warning::Id::from_static("no_periods"),
Self::NoValidTariff => warning::Id::from_static("no_valid_tariff"),
Self::Number(warning) => warning.id(),
Self::Parse(ParseError { object: _, kind }) => kind.id(),
Self::PeriodsOutsideStartEndDateTime { .. } => {
warning::Id::from_static("periods_outside_start_end_date_time")
}
Self::String(warning) => warning.id(),
Self::Tariff(warning) => warning.id(),
}
}
}
from_warning_all!(
country::Warning => Warning::Country,
currency::Warning => Warning::Currency,
datetime::Warning => Warning::DateTime,
duration::Warning => Warning::Duration,
enumeration::Warning => Warning::Enum,
json::decode::Warning => Warning::Decode,
money::Warning => Warning::Money,
number::Warning => Warning::Number,
string::Warning => Warning::String,
crate::tariff::Warning => Warning::Tariff
);
#[derive(Debug)]
pub struct TariffReport {
pub origin: TariffOrigin,
pub warnings: BTreeMap<warning::Path, Vec<crate::tariff::Warning>>,
}
#[derive(Clone, Debug)]
pub struct TariffOrigin {
pub index: usize,
pub id: String,
pub currency: currency::Code,
}
#[derive(Debug)]
pub(crate) struct Period {
pub start_date_time: DateTime<Utc>,
pub consumed: Consumed,
}
#[derive(Debug)]
pub struct Dimensions {
pub energy: Dimension<Kwh>,
pub flat: Dimension<()>,
pub duration_charging: Dimension<TimeDelta>,
pub duration_parking: Dimension<TimeDelta>,
}
impl Dimensions {
fn new(components: ComponentSet, consumed: &Consumed) -> Self {
let ComponentSet {
energy: energy_price,
flat: flat_price,
duration_charging: duration_charging_price,
duration_parking: duration_parking_price,
} = components;
let Consumed {
duration_charging,
duration_parking,
energy,
current_max: _,
current_min: _,
power_max: _,
power_min: _,
} = consumed;
Self {
energy: Dimension {
price: energy_price,
volume: *energy,
billed_volume: *energy,
},
flat: Dimension {
price: flat_price,
volume: Some(()),
billed_volume: Some(()),
},
duration_charging: Dimension {
price: duration_charging_price,
volume: *duration_charging,
billed_volume: *duration_charging,
},
duration_parking: Dimension {
price: duration_parking_price,
volume: *duration_parking,
billed_volume: *duration_parking,
},
}
}
}
#[derive(Debug)]
pub struct Dimension<V> {
pub price: Option<Component>,
pub volume: Option<V>,
pub billed_volume: Option<V>,
}
impl<V: Cost> Dimension<V> {
pub fn cost(&self) -> Option<Price> {
let (Some(volume), Some(price_component)) = (&self.billed_volume, &self.price) else {
return None;
};
let excl_vat = volume.cost(price_component.price);
let incl_vat = match price_component.vat {
VatApplicable::Applicable(vat) => Some(excl_vat.apply_vat(vat)),
VatApplicable::Inapplicable => Some(excl_vat),
VatApplicable::Unknown => None,
};
Some(Price { excl_vat, incl_vat })
}
}
#[derive(Debug)]
pub struct ComponentSet {
pub energy: Option<Component>,
pub flat: Option<Component>,
pub duration_charging: Option<Component>,
pub duration_parking: Option<Component>,
}
impl ComponentSet {
fn has_all_components(&self) -> bool {
let Self {
energy,
flat,
duration_charging,
duration_parking,
} = self;
flat.is_some()
&& energy.is_some()
&& duration_parking.is_some()
&& duration_charging.is_some()
}
}
#[derive(Clone, Debug)]
pub struct Component {
pub tariff_element_index: usize,
pub price: Money,
pub vat: VatApplicable,
pub step_size: u64,
}
impl Component {
fn new(component: &crate::tariff::v221::PriceComponent, tariff_element_index: usize) -> Self {
let crate::tariff::v221::PriceComponent {
price,
vat,
step_size,
dimension_type: _,
} = component;
Self {
tariff_element_index,
price: *price,
vat: *vat,
step_size: *step_size,
}
}
}
#[derive(Debug)]
pub struct Total<TCdr, TCalc = TCdr> {
pub cdr: TCdr,
pub calculated: TCalc,
}
#[derive(Debug)]
pub enum PeriodRange {
Many(Range<DateTime<Utc>>),
Single(DateTime<Utc>),
}
impl fmt::Display for PeriodRange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PeriodRange::Many(Range { start, end }) => write!(f, "[start: {start}, end: {end}]"),
PeriodRange::Single(date_time) => write!(f, "{date_time}"),
}
}
}
#[derive(Debug)]
pub enum TariffSource<'buf> {
UseCdr,
Override(Vec<crate::tariff::Versioned<'buf>>),
}
impl<'buf> TariffSource<'buf> {
pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
Self::Override(vec![tariff])
}
}
#[instrument(skip_all)]
pub(super) fn cdr(
cdr_elem: &crate::cdr::Versioned<'_>,
tariff_source: TariffSource<'_>,
timezone: Tz,
) -> Verdict<Report> {
let cdr = parse_cdr(cdr_elem)?;
match tariff_source {
TariffSource::UseCdr => {
let (v221::cdr::WithTariffs { cdr, tariffs }, warnings) = cdr.into_parts();
debug!("Using tariffs from CDR");
let tariffs = tariffs
.iter()
.map(|elem| {
let tariff = crate::tariff::v211::Tariff::from_json(elem);
tariff.map_caveat(crate::tariff::v221::Tariff::from)
})
.collect::<Result<Vec<_>, _>>()?;
let cdr = cdr.into_caveat(warnings);
Ok(price_v221_cdr_with_tariffs(
cdr_elem, cdr, tariffs, timezone,
)?)
}
TariffSource::Override(tariffs) => {
let cdr = cdr.map(v221::cdr::WithTariffs::discard_tariffs);
debug!("Using override tariffs");
let tariffs = tariffs
.iter()
.map(tariff::parse)
.collect::<Result<Vec<_>, _>>()?;
Ok(price_v221_cdr_with_tariffs(
cdr_elem, cdr, tariffs, timezone,
)?)
}
}
}
fn price_v221_cdr_with_tariffs(
cdr_elem: &crate::cdr::Versioned<'_>,
cdr: Caveat<v221::Cdr, Warning>,
tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
timezone: Tz,
) -> Verdict<Report> {
debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
let (cdr, mut warnings) = cdr.into_parts();
let v221::Cdr {
start_date_time,
end_date_time,
charging_periods,
totals: cdr_totals,
} = cdr;
let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
.into_iter()
.enumerate()
.map(|(index, tariff)| {
let (tariff, warnings) = tariff.into_parts();
(
TariffReport {
origin: TariffOrigin {
index,
id: tariff.id.to_string(),
currency: tariff.currency,
},
warnings: warnings.into_path_map(),
},
tariff,
)
})
.unzip();
debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
let tariffs_normalized = tariff::normalize_all(&tariffs);
let Some((tariff_index, tariff)) =
tariff::find_first_active(tariffs_normalized, start_date_time)
else {
return warnings.bail(Warning::NoValidTariff, cdr_elem.as_element());
};
debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
debug!(%timezone, "Found timezone");
let periods = charging_periods
.into_iter()
.map(Period::try_from)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| warning::ErrorSet::with_warn(Warning::Parse(err), cdr_elem.as_element()))?;
let periods = normalize_periods(periods, end_date_time, timezone);
let price_cdr_report = price_periods(&periods, &tariff)
.with_element(cdr_elem.as_element())?
.gather_warnings_into(&mut warnings);
let report = generate_report(
&cdr_totals,
timezone,
tariff_reports,
price_cdr_report,
TariffOrigin {
index: tariff_index,
id: tariff.id().to_string(),
currency: tariff.currency(),
},
);
Ok(report.into_caveat(warnings))
}
pub(crate) fn periods(
end_date_time: DateTime<Utc>,
timezone: Tz,
tariff_elem: &crate::tariff::v221::Tariff<'_>,
mut periods: Vec<Period>,
) -> VerdictDeferred<PeriodsReport> {
periods.sort_by_key(|p| p.start_date_time);
let tariff = Tariff::from_v221(tariff_elem);
let periods = normalize_periods(periods, end_date_time, timezone);
price_periods(&periods, &tariff)
}
fn normalize_periods(
periods: Vec<Period>,
end_date_time: DateTime<Utc>,
local_timezone: Tz,
) -> Vec<PeriodNormalized> {
debug!("Normalizing CDR periods");
let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
let end_dates = {
let mut end_dates = periods
.iter()
.skip(1)
.map(|p| p.start_date_time)
.collect::<Vec<_>>();
end_dates.push(end_date_time);
end_dates
};
let periods = periods
.into_iter()
.zip(end_dates)
.enumerate()
.map(|(index, (period, end_date_time))| {
trace!(index, "processing\n{period:#?}");
let Period {
start_date_time,
consumed,
} = period;
let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
let start_snapshot = prev_end_snapshot;
let end_snapshot = start_snapshot.next(&consumed, end_date_time);
let period = PeriodNormalized {
consumed,
start_snapshot,
end_snapshot,
};
trace!("Adding new period based on the last added\n{period:#?}");
period
} else {
let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
let end_snapshot = start_snapshot.next(&consumed, end_date_time);
let period = PeriodNormalized {
consumed,
start_snapshot,
end_snapshot,
};
trace!("Adding new period\n{period:#?}");
period
};
previous_end_snapshot.replace(period.end_snapshot.clone());
period
})
.collect::<Vec<_>>();
periods
}
fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
debug!(count = periods.len(), "Pricing CDR periods");
if tracing::enabled!(tracing::Level::TRACE) {
trace!("# CDR period list:");
for period in periods {
trace!("{period:#?}");
}
}
let period_totals = period_totals(periods, tariff);
let (billed, warnings) = period_totals.calculate_billed()?.into_parts();
let (billable, periods, totals) = billed;
let total_costs = total_costs(&periods, tariff);
let report = PeriodsReport {
billable,
periods,
totals,
total_costs,
};
Ok(report.into_caveat_deferred(warnings))
}
pub(crate) struct PeriodsReport {
pub billable: Billable,
pub periods: Vec<PeriodReport>,
pub totals: Totals,
pub total_costs: TotalCosts,
}
#[derive(Debug)]
pub struct PeriodReport {
pub start_date_time: DateTime<Utc>,
pub end_date_time: DateTime<Utc>,
pub dimensions: Dimensions,
}
impl PeriodReport {
fn new(period: &PeriodNormalized, dimensions: Dimensions) -> Self {
Self {
start_date_time: period.start_snapshot.date_time,
end_date_time: period.end_snapshot.date_time,
dimensions,
}
}
pub fn cost(&self) -> Option<Price> {
[
self.dimensions.duration_charging.cost(),
self.dimensions.duration_parking.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)]
struct PeriodTotals {
periods: Vec<PeriodReport>,
step_size: StepSize,
totals: Totals,
}
#[derive(Debug, Default)]
pub(crate) struct Totals {
pub energy: Option<Kwh>,
pub duration_charging: Option<TimeDelta>,
pub duration_parking: Option<TimeDelta>,
}
impl PeriodTotals {
fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
let mut warnings = warning::SetDeferred::new();
let Self {
mut periods,
step_size,
totals,
} = self;
let charging_time = totals
.duration_charging
.map(|dt| step_size.apply_time(&mut periods, dt))
.transpose()?
.gather_deferred_warnings_into(&mut warnings);
let energy = totals
.energy
.map(|kwh| step_size.apply_energy(&mut periods, kwh))
.transpose()?
.gather_deferred_warnings_into(&mut warnings);
let parking_time = totals
.duration_parking
.map(|dt| step_size.apply_parking_time(&mut periods, dt))
.transpose()?
.gather_deferred_warnings_into(&mut warnings);
let billed = Billable {
charging_time,
energy,
parking_time,
};
Ok((billed, periods, totals).into_caveat_deferred(warnings))
}
}
#[derive(Debug)]
pub(crate) struct Billable {
charging_time: Option<TimeDelta>,
energy: Option<Kwh>,
parking_time: Option<TimeDelta>,
}
fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
let mut has_flat_fee = false;
let mut step_size = StepSize::new();
let mut totals = Totals::default();
debug!(
tariff_id = tariff.id(),
period_count = periods.len(),
"Accumulating dimension totals for each period"
);
let periods = periods
.iter()
.enumerate()
.map(|(index, period)| {
let mut component_set = tariff.active_components(period);
trace!(
index,
"Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
);
if component_set.flat.is_some() {
if has_flat_fee {
component_set.flat = None;
} else {
has_flat_fee = true;
}
}
step_size.update(index, &component_set, period);
trace!(period_index = index, "Step size updated\n{step_size:#?}");
let dimensions = Dimensions::new(component_set, &period.consumed);
trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
if let Some(dt) = dimensions.duration_charging.volume {
let acc = totals.duration_charging.get_or_insert_default();
*acc = acc.saturating_add(dt);
}
if let Some(kwh) = dimensions.energy.volume {
let acc = totals.energy.get_or_insert_default();
*acc = acc.saturating_add(kwh);
}
if let Some(dt) = dimensions.duration_parking.volume {
let acc = totals.duration_parking.get_or_insert_default();
*acc = acc.saturating_add(dt);
}
trace!(period_index = index, ?totals, "Update totals");
PeriodReport::new(period, dimensions)
})
.collect::<Vec<_>>();
PeriodTotals {
periods,
step_size,
totals,
}
}
#[derive(Debug, Default)]
pub(crate) struct TotalCosts {
pub energy: Option<Price>,
pub fixed: Option<Price>,
pub duration_charging: Option<Price>,
pub duration_parking: Option<Price>,
}
impl TotalCosts {
pub(crate) fn total(&self) -> Option<Price> {
let Self {
energy,
fixed,
duration_charging,
duration_parking,
} = self;
debug!(
energy = %DisplayOption(*energy),
fixed = %DisplayOption(*fixed),
duration_charging = %DisplayOption(*duration_charging),
duration_parking = %DisplayOption(*duration_parking),
"Calculating total costs."
);
[energy, fixed, duration_charging, duration_parking]
.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()),
),
})
}
}
fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
let mut total_costs = TotalCosts::default();
debug!(
tariff_id = tariff.id(),
period_count = periods.len(),
"Accumulating dimension costs for each period"
);
for (index, period) in periods.iter().enumerate() {
let dimensions = &period.dimensions;
trace!(period_index = index, "Processing period");
let energy_cost = dimensions.energy.cost();
let fixed_cost = dimensions.flat.cost();
let duration_charging_cost = dimensions.duration_charging.cost();
let duration_parking_cost = dimensions.duration_parking.cost();
trace!(?total_costs.energy, ?energy_cost, "Energy cost");
trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
trace!(?total_costs.duration_parking, ?duration_parking_cost, "Parking cost");
trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
total_costs.energy = match (total_costs.energy, energy_cost) {
(None, None) => None,
(total, period) => Some(
total
.unwrap_or_default()
.saturating_add(period.unwrap_or_default()),
),
};
total_costs.duration_charging =
match (total_costs.duration_charging, duration_charging_cost) {
(None, None) => None,
(total, period) => Some(
total
.unwrap_or_default()
.saturating_add(period.unwrap_or_default()),
),
};
total_costs.duration_parking = match (total_costs.duration_parking, duration_parking_cost) {
(None, None) => None,
(total, period) => Some(
total
.unwrap_or_default()
.saturating_add(period.unwrap_or_default()),
),
};
total_costs.fixed = match (total_costs.fixed, fixed_cost) {
(None, None) => None,
(total, period) => Some(
total
.unwrap_or_default()
.saturating_add(period.unwrap_or_default()),
),
};
trace!(period_index = index, ?total_costs, "Update totals");
}
total_costs
}
fn generate_report(
cdr_totals: &v221::cdr::Totals,
timezone: Tz,
tariff_reports: Vec<TariffReport>,
price_periods_report: PeriodsReport,
tariff_used: TariffOrigin,
) -> Report {
let PeriodsReport {
billable,
periods,
totals,
total_costs,
} = price_periods_report;
trace!("Update billed totals {billable:#?}");
let total_cost = total_costs.total();
debug!(total_cost = %DisplayOption(total_cost.as_ref()));
let total_time = {
debug!(
period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
"Calculating `total_time`"
);
periods
.first()
.zip(periods.last())
.map(|(first, last)| {
last.end_date_time
.signed_duration_since(first.start_date_time)
})
.unwrap_or_default()
};
debug!(total_time = %Hms(total_time));
let report = Report {
periods,
tariff_used,
timezone: timezone.to_string(),
billed_parking_time: billable.parking_time,
billed_energy: billable.energy.round_to_ocpi_scale(),
billed_charging_time: billable.charging_time,
tariff_reports,
total_charging_time: totals.duration_charging,
total_cost: Total {
cdr: cdr_totals.cost.round_to_ocpi_scale(),
calculated: total_cost.round_to_ocpi_scale(),
},
total_time_cost: Total {
cdr: cdr_totals.time_cost.round_to_ocpi_scale(),
calculated: total_costs.duration_charging.round_to_ocpi_scale(),
},
total_time: Total {
cdr: cdr_totals.time,
calculated: total_time,
},
total_parking_cost: Total {
cdr: cdr_totals.parking_cost.round_to_ocpi_scale(),
calculated: total_costs.duration_parking.round_to_ocpi_scale(),
},
total_parking_time: Total {
cdr: cdr_totals.parking_time,
calculated: totals.duration_parking,
},
total_energy_cost: Total {
cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
calculated: total_costs.energy.round_to_ocpi_scale(),
},
total_energy: Total {
cdr: cdr_totals.energy.round_to_ocpi_scale(),
calculated: totals.energy.round_to_ocpi_scale(),
},
total_fixed_cost: Total {
cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
calculated: total_costs.fixed.round_to_ocpi_scale(),
},
total_reservation_cost: Total {
cdr: cdr_totals.reservation_cost.round_to_ocpi_scale(),
calculated: None,
},
};
trace!("{report:#?}");
report
}
#[derive(Debug)]
struct StepSize {
charging_time: Option<(usize, Component)>,
parking_time: Option<(usize, Component)>,
energy: Option<(usize, Component)>,
}
fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
Decimal::from(delta.num_milliseconds())
.checked_div(Decimal::from(duration::MILLIS_IN_SEC))
.expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
}
fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
let Ok(millis) = i64::try_from(millis) else {
return Err(warning::ErrorSetDeferred::with_warn(
duration::Warning::Overflow.into(),
));
};
let Some(delta) = TimeDelta::try_milliseconds(millis) else {
return Err(warning::ErrorSetDeferred::with_warn(
duration::Warning::Overflow.into(),
));
};
Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
}
impl StepSize {
fn new() -> Self {
Self {
charging_time: None,
parking_time: None,
energy: None,
}
}
fn update(&mut self, index: usize, components: &ComponentSet, period: &PeriodNormalized) {
if period.consumed.energy.is_some() {
if let Some(energy) = components.energy.clone() {
self.energy = Some((index, energy));
}
}
if period.consumed.duration_charging.is_some() {
if let Some(time) = components.duration_charging.clone() {
self.charging_time = Some((index, time));
}
}
if period.consumed.duration_parking.is_some() {
if let Some(parking) = components.duration_parking.clone() {
self.parking_time = Some((index, parking));
}
}
}
fn duration_step_size(
total_volume: TimeDelta,
period_billed_volume: &mut TimeDelta,
step_size: u64,
) -> VerdictDeferred<TimeDelta> {
if step_size == 0 {
return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
}
let total_seconds = delta_as_seconds_dec(total_volume);
let step_size = Decimal::from(step_size);
let Some(x) = total_seconds.checked_div(step_size) else {
return Err(warning::ErrorSetDeferred::with_warn(
duration::Warning::Overflow.into(),
));
};
let total_billed_volume = delta_from_seconds_dec(x.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 [PeriodReport],
total: TimeDelta,
) -> VerdictDeferred<TimeDelta> {
let (Some((time_index, price)), None) = (&self.charging_time, &self.parking_time) else {
return Ok(total.into_caveat_deferred(warning::SetDeferred::new()));
};
let Some(period) = periods.get_mut(*time_index) else {
error!(time_index, "Invalid period index");
return Err(warning::ErrorSetDeferred::with_warn(Warning::InternalError));
};
let Some(volume) = period.dimensions.duration_charging.billed_volume.as_mut() else {
return Err(warning::ErrorSetDeferred::with_warn(
Warning::DimensionShouldHaveVolume {
dimension_name: "time",
},
));
};
Self::duration_step_size(total, volume, price.step_size)
}
fn apply_parking_time(
&self,
periods: &mut [PeriodReport],
total: TimeDelta,
) -> VerdictDeferred<TimeDelta> {
let warnings = warning::SetDeferred::new();
let Some((parking_index, price)) = &self.parking_time else {
return Ok(total.into_caveat_deferred(warnings));
};
let Some(period) = periods.get_mut(*parking_index) else {
error!(parking_index, "Invalid period index");
return warnings.bail(Warning::InternalError);
};
let Some(volume) = period.dimensions.duration_parking.billed_volume.as_mut() else {
return warnings.bail(Warning::DimensionShouldHaveVolume {
dimension_name: "parking_time",
});
};
Self::duration_step_size(total, volume, price.step_size)
}
fn apply_energy(
&self,
periods: &mut [PeriodReport],
total_volume: Kwh,
) -> VerdictDeferred<Kwh> {
let warnings = warning::SetDeferred::new();
let Some((energy_index, price)) = &self.energy else {
return Ok(total_volume.into_caveat_deferred(warnings));
};
if price.step_size == 0 {
return Ok(total_volume.into_caveat_deferred(warnings));
}
let Some(period) = periods.get_mut(*energy_index) else {
error!(energy_index, "Invalid period index");
return warnings.bail(Warning::InternalError);
};
let step_size = Decimal::from(price.step_size);
let Some(period_billed_volume) = period.dimensions.energy.billed_volume.as_mut() else {
return warnings.bail(Warning::DimensionShouldHaveVolume {
dimension_name: "energy",
});
};
let Some(watt_hours) = total_volume.watt_hours().checked_div(step_size) else {
return warnings.bail(duration::Warning::Overflow.into());
};
let total_billed_volume = Kwh::from_watt_hours(watt_hours.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.into_caveat_deferred(warnings))
}
}
fn parse_cdr<'buf>(cdr: &crate::cdr::Versioned<'buf>) -> Verdict<v221::cdr::WithTariffs<'buf>> {
match cdr.version() {
Version::V211 => {
let cdr = v211::cdr::WithTariffs::from_json(cdr.as_element())?;
Ok(cdr.map(v221::cdr::WithTariffs::from))
}
Version::V221 => v221::cdr::WithTariffs::from_json(cdr.as_element()),
}
}