use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use crate::{
explain::{
ir::{
Body, Bounds, Condition, ConditionPart, Dimension, Explanation, Fallback, Flat,
FlatFee, FlatTier, Rate, Scope, Section, Tier, TimeWindow, Validity,
},
render::{render, Language},
},
json::FromJson as _,
money::VatOrigin,
tariff::{
v221::{Element, Restrictions, Tariff},
v2x::DimensionType,
Warning,
},
warning::VerdictExt as _,
Money, Price, Verdict, Version, Versioned as _,
};
pub(crate) fn explain(
tariff: &crate::tariff::Versioned<'_>,
language: Language,
) -> Verdict<String, Warning> {
let parsed = match tariff.version() {
Version::V211 => {
crate::tariff::v211::Tariff::from_json(tariff.as_element()).map_caveat(Tariff::from)
}
Version::V221 => Tariff::from_json(tariff.as_element()),
};
parsed.map_caveat(|tariff| render(&build(&tariff), language))
}
pub(super) fn build(tariff: &Tariff<'_>) -> Explanation {
let elements = &tariff.elements;
let mut sections: Vec<Section> = Vec::new();
for dimension in [
DimensionType::Energy,
DimensionType::Time,
DimensionType::ParkingTime,
] {
if let Some(section) = build_dimension(elements, dimension) {
sections.push(Section::Dimension(section));
}
}
if let Some(section) = build_flat(elements) {
sections.push(Section::Flat(section));
}
if let Some(section) = build_bounds(tariff.min_price, tariff.max_price) {
sections.push(Section::Bounds(section));
}
if let Some(section) = build_validity(tariff.start_date_time, tariff.end_date_time) {
sections.push(Section::Validity(section));
}
let body = if sections.is_empty() {
Body::Fallback(fallback_reason(tariff))
} else {
Body::Sections(sections)
};
Explanation {
currency: tariff.currency,
body,
}
}
fn fallback_reason(tariff: &Tariff<'_>) -> Fallback {
let elements = &tariff.elements;
if elements.iter().all(is_reservation_only) {
return Fallback::ReservationOnly;
}
let has_components = elements
.iter()
.filter(|element| !is_reservation_only(element))
.any(|element| !element.price_components.is_empty());
if !has_components {
return Fallback::NoPriceComponents;
}
Fallback::FreeFlatOnly
}
fn is_reservation_only(element: &Element) -> bool {
element
.restrictions
.as_ref()
.is_some_and(|restrictions| restrictions.reservation.is_some())
}
struct Band<'a> {
price: Money,
vat: VatOrigin,
step_size: u64,
restrictions: Option<&'a Restrictions>,
}
fn bands(elements: &[Element], dimension: DimensionType) -> Vec<Band<'_>> {
let mut bands = Vec::new();
for element in elements {
if is_reservation_only(element) {
continue;
}
if let Some(component) = element
.price_components
.iter()
.find(|component| component.dimension_type == dimension)
{
bands.push(Band {
price: component.price,
vat: component.vat,
step_size: component.step_size,
restrictions: element.restrictions.as_ref(),
});
}
}
bands
}
fn build_flat(elements: &[Element]) -> Option<Flat> {
let mut bands = bands(elements, DimensionType::Flat);
let reachable = bands
.iter()
.position(|band| {
band.restrictions
.is_none_or(|restrictions| flat_condition(restrictions).is_empty())
})
.map(|index| index.saturating_add(1))
.unwrap_or(bands.len());
bands.truncate(reachable);
let tiers: Vec<FlatTier> = bands
.iter()
.enumerate()
.map(|(index, band)| {
let parts = band.restrictions.map(flat_condition).unwrap_or_default();
let condition = if !parts.is_empty() {
Condition::When(parts)
} else if index > 0 {
Condition::Otherwise
} else {
Condition::Always
};
let fee = if is_free(band.price) {
FlatFee::NoFee
} else {
FlatFee::Charged {
amount: band.price,
vat: band.vat,
}
};
FlatTier { condition, fee }
})
.collect();
match tiers.as_slice() {
[] => None,
[tier] if matches!(tier.fee, FlatFee::NoFee) => None,
_ => Some(Flat { tiers }),
}
}
fn flat_condition(restrictions: &Restrictions) -> Vec<ConditionPart> {
let mut parts = qualifiers(restrictions);
if let Some(scope) = duration_scope(restrictions) {
parts.push(ConditionPart::DurationScope(scope));
}
if let Some(scope) = energy_scope(restrictions) {
parts.push(ConditionPart::EnergyScope(scope));
}
parts
}
fn build_dimension(elements: &[Element], dimension: DimensionType) -> Option<Dimension> {
let bands = bands(elements, dimension);
if bands.is_empty() {
return None;
}
let pieces: Vec<(
Option<ConditionPart>,
Option<ConditionPart>,
Vec<ConditionPart>,
)> = bands
.iter()
.map(|band| {
let primary = band
.restrictions
.and_then(|restrictions| primary_scope(restrictions, dimension));
let secondary = band
.restrictions
.and_then(|restrictions| secondary_scope(restrictions, dimension));
let qualifiers = band.restrictions.map(qualifiers).unwrap_or_default();
(primary, secondary, qualifiers)
})
.collect();
let reachable = pieces
.iter()
.position(|(primary, secondary, qualifiers)| {
primary.is_none() && secondary.is_none() && qualifiers.is_empty()
})
.map(|index| index.saturating_add(1))
.unwrap_or(pieces.len());
let dropped = pieces.len().saturating_sub(reachable);
let first_step = bands.first().map(|band| band.step_size);
let uniform = first_step.is_some_and(|step| {
bands
.iter()
.take(reachable)
.all(|band| band.step_size == step)
});
let uniform_step = first_step.filter(|&step| uniform && step != 1);
let mut seen_bound = false;
let mut seen_qualifier = false;
let mut tiers: Vec<Tier> = Vec::with_capacity(reachable);
for (index, (band, (primary, secondary, qualifiers))) in
bands.iter().zip(pieces).enumerate().take(reachable)
{
let has_qualifier = !qualifiers.is_empty() || secondary.is_some();
let primary_present = primary.is_some();
let is_catch_all = primary.is_none() && secondary.is_none() && qualifiers.is_empty();
let condition = if !is_catch_all {
let mut parts = qualifiers;
if let Some(primary) = primary {
parts.push(primary);
}
if let Some(secondary) = secondary {
parts.push(secondary);
}
Condition::When(parts)
} else if index == 0 {
Condition::Always
} else if seen_bound && !seen_qualifier {
Condition::Remaining
} else {
Condition::Otherwise
};
let free = is_free(band.price);
let rate = if free {
Rate::Free
} else {
Rate::Priced {
amount: band.price,
vat: band.vat,
}
};
let step = if uniform || band.step_size == 1 || free {
None
} else {
Some(band.step_size)
};
seen_bound |= primary_present;
seen_qualifier |= has_qualifier;
tiers.push(Tier {
condition,
rate,
step,
});
}
Some(Dimension {
kind: dimension,
tiers,
uniform_step,
dropped_unreachable: dropped > 0,
})
}
fn primary_scope(restrictions: &Restrictions, dimension: DimensionType) -> Option<ConditionPart> {
match dimension {
DimensionType::Time | DimensionType::ParkingTime => {
duration_scope(restrictions).map(ConditionPart::DurationScope)
}
DimensionType::Energy => energy_scope(restrictions).map(ConditionPart::EnergyScope),
DimensionType::Flat => None,
}
}
fn secondary_scope(restrictions: &Restrictions, dimension: DimensionType) -> Option<ConditionPart> {
match dimension {
DimensionType::Time | DimensionType::ParkingTime => {
energy_scope(restrictions).map(ConditionPart::EnergyScope)
}
DimensionType::Energy => duration_scope(restrictions).map(ConditionPart::DurationScope),
DimensionType::Flat => None,
}
}
fn duration_scope(restrictions: &Restrictions) -> Option<Scope<chrono::TimeDelta>> {
match (restrictions.min_duration, restrictions.max_duration) {
(None, Some(max)) => Some(Scope::UpTo(max)),
(Some(min), None) => Some(Scope::After(min)),
(Some(min), Some(max)) => Some(Scope::Between(min, max)),
(None, None) => None,
}
}
fn energy_scope(restrictions: &Restrictions) -> Option<Scope<crate::Kwh>> {
match (restrictions.min_kwh, restrictions.max_kwh) {
(None, Some(max)) => Some(Scope::UpTo(max)),
(Some(min), None) => Some(Scope::After(min)),
(Some(min), Some(max)) => Some(Scope::Between(min, max)),
(None, None) => None,
}
}
fn qualifiers(restrictions: &Restrictions) -> Vec<ConditionPart> {
let mut parts = Vec::new();
match (restrictions.start_time, restrictions.end_time) {
(Some(start), Some(end)) if start == end => {
parts.push(ConditionPart::TimeWindow(TimeWindow::Empty { start, end }));
}
(Some(start), Some(end)) if end < start => {
parts.push(ConditionPart::TimeWindow(TimeWindow::Wrapping {
start,
end,
}));
}
(Some(start), Some(end)) => {
parts.push(ConditionPart::TimeWindow(TimeWindow::Between {
start,
end,
}));
}
(Some(start), None) => parts.push(ConditionPart::TimeWindow(TimeWindow::From { start })),
(None, Some(end)) => parts.push(ConditionPart::TimeWindow(TimeWindow::Before { end })),
(None, None) => {}
}
if let Some(days) = restrictions.day_of_week.as_ref().filter(|d| !d.is_empty()) {
parts.push(ConditionPart::Weekdays(days.clone()));
}
match (restrictions.start_date, restrictions.end_date) {
(None, None) => {}
(start, end) => parts.push(ConditionPart::DateRange { start, end }),
}
if let Some(min) = restrictions.min_power {
parts.push(ConditionPart::MinPower(min));
}
if let Some(max) = restrictions.max_power {
parts.push(ConditionPart::MaxPower(max));
}
if let Some(min) = restrictions.min_current {
parts.push(ConditionPart::MinCurrent(min));
}
if let Some(max) = restrictions.max_current {
parts.push(ConditionPart::MaxCurrent(max));
}
parts
}
fn build_bounds(min_price: Option<Price>, max_price: Option<Price>) -> Option<Bounds> {
if min_price.is_none() && max_price.is_none() {
None
} else {
Some(Bounds {
min: min_price,
max: max_price,
})
}
}
fn build_validity(
start_date_time: Option<DateTime<Utc>>,
end_date_time: Option<DateTime<Utc>>,
) -> Option<Validity> {
match (start_date_time, end_date_time) {
(Some(start), Some(end)) => Some(Validity::Between { start, end }),
(Some(start), None) => Some(Validity::From { start }),
(None, Some(end)) => Some(Validity::Until { end }),
(None, None) => None,
}
}
fn is_free(money: Money) -> bool {
Decimal::from(money) == Decimal::ZERO
}