use chrono::{DateTime, NaiveDate, 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},
},
money::VatOrigin,
tariff::{
v221::{Element, Restrictions, Tariff},
v2x::DimensionType,
Warning,
},
warning::VerdictExt as _,
Money, Price, Verdict,
};
pub(crate) fn explain(
tariff: &crate::tariff::Versioned<'_>,
language: Language,
) -> Verdict<String, Warning> {
let parsed = tariff.to_v221();
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 mut 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();
let conditions: Vec<&Condition> = tiers.iter().map(|tier| &tier.condition).collect();
let order = chronological_order(&conditions);
let mut dropped_shadowed = false;
if let Some(order) = order {
dropped_shadowed = order.dropped_shadowed;
tiers = keep_in_order(tiers, &order.keep);
}
match tiers.as_slice() {
[] => None,
[tier] if matches!(tier.fee, FlatFee::NoFee) => None,
_ => Some(Flat {
tiers,
dropped_shadowed,
}),
}
}
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,
});
}
let conditions: Vec<&Condition> = tiers.iter().map(|tier| &tier.condition).collect();
let order = chronological_order(&conditions);
let mut dropped_shadowed = false;
if let Some(order) = order {
dropped_shadowed = order.dropped_shadowed;
tiers = keep_in_order(tiers, &order.keep);
}
Some(Dimension {
kind: dimension,
tiers,
uniform_step,
dropped_unreachable: dropped > 0,
dropped_shadowed,
})
}
struct DateOrder {
keep: Vec<usize>,
dropped_shadowed: bool,
}
struct DateSpan {
index: usize,
start: NaiveDate,
end: NaiveDate,
}
fn chronological_order(conditions: &[&Condition]) -> Option<DateOrder> {
let mut spans: Vec<DateSpan> = Vec::with_capacity(conditions.len());
for (index, condition) in conditions.iter().enumerate() {
let (start, end) = date_span(condition)?;
spans.push(DateSpan { index, start, end });
}
let mut covered: Vec<(NaiveDate, NaiveDate)> = Vec::with_capacity(spans.len());
let mut kept: Vec<DateSpan> = Vec::with_capacity(spans.len());
let mut dropped_shadowed = false;
for span in spans {
if is_covered(&covered, span.start, span.end) {
dropped_shadowed = true;
continue;
}
covered.push((span.start, span.end));
kept.push(span);
}
kept.sort_by_key(|span| (span.start, span.end));
for pair in kept.windows(2) {
let [earlier, later] = pair else {
continue;
};
if earlier.end > later.start {
return None;
}
}
Some(DateOrder {
keep: kept.iter().map(|span| span.index).collect(),
dropped_shadowed,
})
}
fn date_span(condition: &Condition) -> Option<(NaiveDate, NaiveDate)> {
let Condition::When(parts) = condition else {
return None;
};
let [ConditionPart::DateRange { start, end }] = parts.as_slice() else {
return None;
};
Some((
start.unwrap_or(NaiveDate::MIN),
end.unwrap_or(NaiveDate::MAX),
))
}
fn is_covered(covered: &[(NaiveDate, NaiveDate)], start: NaiveDate, end: NaiveDate) -> bool {
if start >= end {
return false;
}
let mut sorted = covered.to_vec();
sorted.sort_unstable();
let mut reached = start;
for (covered_start, covered_end) in sorted {
if covered_start > reached {
return false;
}
reached = reached.max(covered_end);
if reached >= end {
return true;
}
}
false
}
fn keep_in_order<T>(tiers: Vec<T>, keep: &[usize]) -> Vec<T> {
let mut slots: Vec<Option<T>> = tiers.into_iter().map(Some).collect();
let mut ordered: Vec<T> = Vec::with_capacity(keep.len());
for &index in keep {
let Some(tier) = slots.get_mut(index).and_then(Option::take) else {
continue;
};
ordered.push(tier);
}
ordered
}
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
}