use time::{Date, macros::date};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Lokationsebene {
Marktlokation,
Messlokation,
Netzlokation,
Tranche,
}
impl Lokationsebene {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Marktlokation => "Marktlokation",
Self::Messlokation => "Messlokation",
Self::Netzlokation => "Netzlokation",
Self::Tranche => "Tranche",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Uebertragungsweg {
Backend,
Smgw,
}
impl Uebertragungsweg {
#[must_use]
pub const fn lin_code(self) -> &'static str {
match self {
Self::Backend => "Z67",
Self::Smgw => "Z68",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Wert {
Wirkarbeit,
Blindarbeit,
Momentanwerte,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Werteart {
Lastgang,
Zaehlerstandsgang,
Arbeitsmenge,
Momentanwerte,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Energieflussrichtung {
Verbrauch,
Erzeugung,
VerbrauchUndErzeugung,
Q1Q4,
Q2Q3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Lieferrhythmus {
TaeglichBis0930,
WimKapitel255,
Direktverbindung,
}
pub const ROHDATEN_FRIST: time::Time = time::macros::time!(9:30);
impl Lieferrhythmus {
#[must_use]
pub const fn taegliche_frist(self) -> Option<time::Time> {
match self {
Self::TaeglichBis0930 => Some(ROHDATEN_FRIST),
Self::WimKapitel255 | Self::Direktverbindung => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verbindlichkeit {
Pflicht {
ab: Date,
},
Optional,
}
impl Verbindlichkeit {
#[must_use]
pub const fn ist_pflicht_am(self, am: Date) -> bool {
match self {
Self::Pflicht { ab } => am.to_julian_day() >= ab.to_julian_day(),
Self::Optional => false,
}
}
#[must_use]
pub const fn jemals_pflicht(self) -> bool {
matches!(self, Self::Pflicht { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Messprodukt {
pub code: &'static str,
pub bezeichnung: &'static str,
pub weg: Uebertragungsweg,
pub ebene: Lokationsebene,
pub wert: Wert,
pub werteart: Werteart,
pub richtung: Energieflussrichtung,
pub rhythmus: Lieferrhythmus,
pub verbindlichkeit: Verbindlichkeit,
pub nutzbar_ab: Date,
pub schwellwertgesteuert: bool,
}
macro_rules! produkt {
(
$code:literal, $bez:literal, $weg:ident, $ebene:ident, $wert:ident,
$art:ident, $ri:ident, $rh:ident, $vb:expr, $nutzbar_ab:expr
) => {
Messprodukt {
code: $code,
bezeichnung: $bez,
weg: Uebertragungsweg::$weg,
ebene: Lokationsebene::$ebene,
wert: Wert::$wert,
werteart: Werteart::$art,
richtung: Energieflussrichtung::$ri,
rhythmus: Lieferrhythmus::$rh,
verbindlichkeit: $vb,
nutzbar_ab: $nutzbar_ab,
schwellwertgesteuert: false,
}
};
}
const PFLICHT: Verbindlichkeit = Verbindlichkeit::Pflicht {
ab: date!(2024 - 08 - 06),
};
pub const BACKEND_PRODUKTE: &[Messprodukt] = &[
produkt!(
"9991000000416",
"ESA, Messlokation Wirkarbeit Lastgang Verbrauch 1/4 stündlich, Rohdaten",
Backend,
Messlokation,
Wirkarbeit,
Lastgang,
Verbrauch,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2022 - 04 - 01)
),
produkt!(
"9991000000424",
"ESA, Messlokation Wirkarbeit Lastgang Erzeugung 1/4 stündlich, Rohdaten",
Backend,
Messlokation,
Wirkarbeit,
Lastgang,
Erzeugung,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2022 - 04 - 01)
),
produkt!(
"9991000000458",
"ESA, Messlokation Blindarbeit Lastgang Verbrauch 1/4 stündlich, Rohdaten",
Backend,
Messlokation,
Blindarbeit,
Lastgang,
Q1Q4,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2022 - 04 - 01)
),
produkt!(
"9991000000466",
"ESA, Messlokation Blindarbeit Lastgang Erzeugung 1/4 stündlich, Rohdaten",
Backend,
Messlokation,
Blindarbeit,
Lastgang,
Q2Q3,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2022 - 04 - 01)
),
produkt!(
"9991000000747",
"ESA, Marktlokation Wirkarbeit Lastgang Verbrauch oder Erzeugung 1/4 stündlich, aufbereitete Daten",
Backend,
Marktlokation,
Wirkarbeit,
Lastgang,
VerbrauchUndErzeugung,
WimKapitel255,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
produkt!(
"9991000003056",
"ESA, Marktlokation Wirkarbeit Lastgang Verbrauch oder Erzeugung 1/4 stündlich, aufbereitete Daten",
Backend,
Marktlokation,
Wirkarbeit,
Lastgang,
VerbrauchUndErzeugung,
WimKapitel255,
PFLICHT,
date!(2024 - 08 - 06)
),
produkt!(
"9991000000755",
"ESA, Tranche Wirkarbeit Lastgang Erzeugung 1/4 stündlich, aufbereitete Daten",
Backend,
Tranche,
Wirkarbeit,
Lastgang,
Erzeugung,
WimKapitel255,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
produkt!(
"9991000003064",
"ESA, Tranche Wirkarbeit Lastgang Erzeugung 1/4 stündlich, aufbereitete Daten",
Backend,
Tranche,
Wirkarbeit,
Lastgang,
Erzeugung,
WimKapitel255,
PFLICHT,
date!(2024 - 08 - 06)
),
produkt!(
"9991000001539",
"ESA, Marktlokation Blindarbeit Lastgang 1/4 stündlich, aufbereitete Daten",
Backend,
Marktlokation,
Blindarbeit,
Lastgang,
VerbrauchUndErzeugung,
WimKapitel255,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
produkt!(
"9991000000763",
"ESA, Netzlokation Blindarbeit Lastgang 1/4 stündlich, aufbereitete Daten",
Backend,
Netzlokation,
Blindarbeit,
Lastgang,
VerbrauchUndErzeugung,
WimKapitel255,
Verbindlichkeit::Optional,
date!(2024 - 01 - 01)
),
produkt!(
"9991000000771",
"ESA, Messlokation Wirkarbeit Lastgang Verbrauch 1/4 stündlich, aufbereitete Daten",
Backend,
Messlokation,
Wirkarbeit,
Lastgang,
Verbrauch,
WimKapitel255,
PFLICHT,
date!(2023 - 10 - 01)
),
produkt!(
"9991000000789",
"ESA, Messlokation Wirkarbeit Lastgang Erzeugung 1/4 stündlich, aufbereitete Daten",
Backend,
Messlokation,
Wirkarbeit,
Lastgang,
Erzeugung,
WimKapitel255,
PFLICHT,
date!(2023 - 10 - 01)
),
produkt!(
"9991000000797",
"ESA, Messlokation Blindarbeit Lastgang Verbrauch 1/4 stündlich, aufbereitete Daten",
Backend,
Messlokation,
Blindarbeit,
Lastgang,
Q1Q4,
WimKapitel255,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
produkt!(
"9991000000804",
"ESA, Messlokation Blindarbeit Lastgang Erzeugung 1/4 stündlich, aufbereitete Daten",
Backend,
Messlokation,
Blindarbeit,
Lastgang,
Q2Q3,
WimKapitel255,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
produkt!(
"9991000001505",
"ESA, Messlokation Zählerstandsgang Erzeugung 1/4 stündlich, Rohdaten",
Backend,
Messlokation,
Wirkarbeit,
Zaehlerstandsgang,
Erzeugung,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2023 - 08 - 01)
),
produkt!(
"9991000001513",
"ESA, Marktlokation Zählerstandsgang Verbrauch / Erzeugung 1/4 stündlich, Rohdaten",
Backend,
Marktlokation,
Wirkarbeit,
Zaehlerstandsgang,
VerbrauchUndErzeugung,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2023 - 08 - 01)
),
produkt!(
"9991000001521",
"ESA, Messlokation Zählerstandsgang Verbrauch 1/4 stündlich, Rohdaten",
Backend,
Messlokation,
Wirkarbeit,
Zaehlerstandsgang,
Verbrauch,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2023 - 08 - 01)
),
produkt!(
"9991000003147",
"ESA, Marktlokation, Energiemenge, aufbereitete Daten",
Backend,
Marktlokation,
Wirkarbeit,
Arbeitsmenge,
VerbrauchUndErzeugung,
WimKapitel255,
PFLICHT,
date!(2024 - 08 - 06)
),
];
pub const SMGW_PRODUKTE: &[Messprodukt] = &[
produkt!(
"9991000000432",
"ESA, Messlokation Wirkarbeit Lastgang Verbrauch 1/4 stündlich aus dem SMGW",
Smgw,
Messlokation,
Wirkarbeit,
Lastgang,
Verbrauch,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2022 - 04 - 01)
),
produkt!(
"9991000003121",
"ESA, Messlokation Wirkarbeit Lastgang Verbrauch 1/4 stündlich aus dem SMGW, Rohdaten",
Smgw,
Messlokation,
Wirkarbeit,
Lastgang,
Verbrauch,
TaeglichBis0930,
PFLICHT,
date!(2024 - 08 - 06)
),
produkt!(
"9991000000440",
"ESA, Messlokation Wirkarbeit Lastgang Erzeugung 1/4 stündlich aus dem SMGW",
Smgw,
Messlokation,
Wirkarbeit,
Lastgang,
Erzeugung,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2022 - 04 - 01)
),
produkt!(
"9991000003139",
"ESA, Messlokation Wirkarbeit Lastgang Erzeugung 1/4 stündlich aus dem SMGW, Rohdaten",
Smgw,
Messlokation,
Wirkarbeit,
Lastgang,
Erzeugung,
TaeglichBis0930,
PFLICHT,
date!(2024 - 08 - 06)
),
produkt!(
"9991000000474",
"ESA, Messlokation Blindarbeit Lastgang Verbrauch 1/4 stündlich aus dem SMGW",
Smgw,
Messlokation,
Blindarbeit,
Lastgang,
Q1Q4,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2022 - 04 - 01)
),
produkt!(
"9991000000482",
"ESA, Messlokation Blindarbeit Lastgang Erzeugung 1/4 stündlich aus dem SMGW",
Smgw,
Messlokation,
Blindarbeit,
Lastgang,
Q2Q3,
TaeglichBis0930,
Verbindlichkeit::Optional,
date!(2022 - 04 - 01)
),
produkt!(
"9991000001183",
"Messlokation, Ist-Einspeisung, 1 Min.",
Smgw,
Messlokation,
Momentanwerte,
Momentanwerte,
Erzeugung,
Direktverbindung,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
produkt!(
"9991000001191",
"Messlokation, Ist-Einspeisung, 15 Min.",
Smgw,
Messlokation,
Momentanwerte,
Momentanwerte,
Erzeugung,
Direktverbindung,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
produkt!(
"9991000001208",
"Messlokation, Ist-Einspeisung, zur einmaligen Übermittlung",
Smgw,
Messlokation,
Momentanwerte,
Momentanwerte,
Erzeugung,
Direktverbindung,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
Messprodukt {
code: "9991000001216",
bezeichnung: "Messlokation, Ist-Einspeisung, Schwellwert",
weg: Uebertragungsweg::Smgw,
ebene: Lokationsebene::Messlokation,
wert: Wert::Momentanwerte,
werteart: Werteart::Momentanwerte,
richtung: Energieflussrichtung::Erzeugung,
rhythmus: Lieferrhythmus::Direktverbindung,
verbindlichkeit: Verbindlichkeit::Optional,
nutzbar_ab: date!(2023 - 10 - 01),
schwellwertgesteuert: true,
},
produkt!(
"9991000001224",
"Messlokation, Mehrwertdienste, 1 Min.",
Smgw,
Messlokation,
Momentanwerte,
Momentanwerte,
VerbrauchUndErzeugung,
Direktverbindung,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
produkt!(
"9991000001232",
"Messlokation, Mehrwertdienste, 15 Min.",
Smgw,
Messlokation,
Momentanwerte,
Momentanwerte,
VerbrauchUndErzeugung,
Direktverbindung,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
produkt!(
"9991000001240",
"Messlokation, Mehrwertdienste, zur einmaligen Übermittlung",
Smgw,
Messlokation,
Momentanwerte,
Momentanwerte,
VerbrauchUndErzeugung,
Direktverbindung,
Verbindlichkeit::Optional,
date!(2023 - 10 - 01)
),
Messprodukt {
code: "9991000001258",
bezeichnung: "Messlokation, Mehrwertdienste, Schwellwert",
weg: Uebertragungsweg::Smgw,
ebene: Lokationsebene::Messlokation,
wert: Wert::Momentanwerte,
werteart: Werteart::Momentanwerte,
richtung: Energieflussrichtung::VerbrauchUndErzeugung,
rhythmus: Lieferrhythmus::Direktverbindung,
verbindlichkeit: Verbindlichkeit::Optional,
nutzbar_ab: date!(2023 - 10 - 01),
schwellwertgesteuert: true,
},
];
#[must_use]
pub fn normalize_code(code: &str) -> String {
code.chars().filter(char::is_ascii_digit).collect()
}
#[must_use]
pub fn messprodukt(code: &str) -> Option<&'static Messprodukt> {
let normalized = normalize_code(code);
BACKEND_PRODUKTE
.iter()
.chain(SMGW_PRODUKTE)
.find(|p| p.code == normalized)
}
pub fn pflichtprodukte() -> impl Iterator<Item = &'static Messprodukt> {
BACKEND_PRODUKTE
.iter()
.chain(SMGW_PRODUKTE)
.filter(|p| p.verbindlichkeit.jemals_pflicht())
}
#[must_use]
pub fn ebene_fuer_messprodukt(code: &str) -> Option<Lokationsebene> {
messprodukt(code).map(|p| p.ebene)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProduktFehler {
UnbekanntesMessprodukt {
code: String,
},
EbeneStimmtNicht {
produkt: Lokationsebene,
anfrage: Lokationsebene,
},
NochNichtNutzbar {
nutzbar_ab: Date,
wunschtermin: Date,
},
SmgwZielFehlt,
SchwellwertFehlt,
SmgwZielUnzulaessig,
}
impl std::fmt::Display for ProduktFehler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnbekanntesMessprodukt { code } => write!(
f,
"Messprodukt {code} steht nicht in der Codeliste der Konfigurationen 1.4 \
Kapitel 4.6 — nur diese Produkte darf die Marktrolle ESA bestellen"
),
Self::EbeneStimmtNicht { produkt, anfrage } => write!(
f,
"Messprodukt ist für die Ebene {} definiert, die Werteanfrage adressiert \
aber eine {}",
produkt.as_str(),
anfrage.as_str()
),
Self::NochNichtNutzbar {
nutzbar_ab,
wunschtermin,
} => write!(
f,
"Messprodukt ist erst ab {nutzbar_ab} nutzbar, Wunschtermin ist {wunschtermin}"
),
Self::SmgwZielFehlt => f.write_str(
"Messprodukt aus Kapitel 4.6.2 (Werte aus SMGW) verlangt Zieladresse (IPv4 und \
IPv6) sowie Zertifikatsaussteller und -nutzer",
),
Self::SchwellwertFehlt => f.write_str(
"schwellwertgesteuertes Messprodukt verlangt mindestens einen \
Messprodukt-Position-Code mit oberem und unterem Schwellwert",
),
Self::SmgwZielUnzulaessig => f.write_str(
"Zieladresse/Zertifikate gehören zu Kapitel 4.6.2; ein Backend-Messprodukt \
(4.6.1) trägt sie nicht",
),
}
}
}
impl std::error::Error for ProduktFehler {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Abonnement {
StartAbo,
EndeAbo,
OhneAbo,
}
impl Abonnement {
#[must_use]
pub const fn imd_code(self) -> &'static str {
match self {
Self::StartAbo => "Z01",
Self::EndeAbo => "Z02",
Self::OhneAbo => "Z03",
}
}
#[must_use]
pub fn from_imd_code(code: &str) -> Option<Self> {
match code {
"Z01" => Some(Self::StartAbo),
"Z02" => Some(Self::EndeAbo),
"Z03" => Some(Self::OhneAbo),
_ => None,
}
}
#[must_use]
pub const fn ist_abo(self) -> bool {
matches!(self, Self::StartAbo)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Schwellwert {
pub position_code: String,
pub oberer: String,
pub unterer: String,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SmgwZiel {
pub uri_ipv4: String,
pub uri_ipv6: String,
pub zertifikat_aussteller: String,
pub zertifikat_nutzer: String,
#[serde(default)]
pub schwellwerte: Vec<Schwellwert>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Bestellgegenstand {
pub messprodukt: String,
pub wunschtermin: Date,
#[serde(default)]
pub zeitraum_bis: Option<Date>,
pub abonnement: Abonnement,
#[serde(default)]
pub smgw: Option<SmgwZiel>,
}
impl Messprodukt {
#[must_use]
pub const fn ist_pflicht_am(&self, am: Date) -> bool {
self.verbindlichkeit.ist_pflicht_am(am)
}
}
impl Bestellgegenstand {
pub fn produkt(&self) -> Result<&'static Messprodukt, ProduktFehler> {
messprodukt(&self.messprodukt).ok_or_else(|| ProduktFehler::UnbekanntesMessprodukt {
code: self.messprodukt.clone(),
})
}
pub fn validate(&self, anfrage_ebene: Lokationsebene) -> Result<(), ProduktFehler> {
let p = self.produkt()?;
if p.ebene != anfrage_ebene {
return Err(ProduktFehler::EbeneStimmtNicht {
produkt: p.ebene,
anfrage: anfrage_ebene,
});
}
if self.wunschtermin < p.nutzbar_ab {
return Err(ProduktFehler::NochNichtNutzbar {
nutzbar_ab: p.nutzbar_ab,
wunschtermin: self.wunschtermin,
});
}
match (p.weg, self.smgw.as_ref()) {
(Uebertragungsweg::Smgw, None) => return Err(ProduktFehler::SmgwZielFehlt),
(Uebertragungsweg::Backend, Some(_)) => {
return Err(ProduktFehler::SmgwZielUnzulaessig);
}
(Uebertragungsweg::Smgw, Some(ziel)) => {
if ziel.uri_ipv4.is_empty()
|| ziel.uri_ipv6.is_empty()
|| ziel.zertifikat_aussteller.is_empty()
|| ziel.zertifikat_nutzer.is_empty()
{
return Err(ProduktFehler::SmgwZielFehlt);
}
if p.schwellwertgesteuert && ziel.schwellwerte.is_empty() {
return Err(ProduktFehler::SchwellwertFehlt);
}
}
(Uebertragungsweg::Backend, None) => {}
}
Ok(())
}
}
#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Geraeteausstattung {
pub ist_ims: bool,
pub hat_verbrauchsregister: bool,
pub hat_erzeugungsregister: bool,
pub hat_blindarbeitsregister: bool,
}
#[must_use]
pub fn geraetetechnik_geeignet(
produkt: &Messprodukt,
ausstattung: Option<&Geraeteausstattung>,
intervallfaehig: Option<bool>,
) -> Option<bool> {
let a = ausstattung?;
if produkt.weg == Uebertragungsweg::Smgw && !a.ist_ims {
return Some(false);
}
if produkt.wert == Wert::Blindarbeit && !a.hat_blindarbeitsregister {
return Some(false);
}
let braucht_verbrauch = matches!(
produkt.richtung,
Energieflussrichtung::Verbrauch
| Energieflussrichtung::VerbrauchUndErzeugung
| Energieflussrichtung::Q1Q4
);
let braucht_erzeugung = matches!(
produkt.richtung,
Energieflussrichtung::Erzeugung
| Energieflussrichtung::VerbrauchUndErzeugung
| Energieflussrichtung::Q2Q3
);
if braucht_verbrauch && !a.hat_verbrauchsregister {
return Some(false);
}
if braucht_erzeugung && !a.hat_erzeugungsregister {
return Some(false);
}
if produkt.braucht_intervallmessung() && !a.ist_ims {
return match intervallfaehig {
Some(true) => Some(true),
Some(false) => Some(false),
None => None,
};
}
Some(true)
}
impl Messprodukt {
#[must_use]
pub const fn braucht_intervallmessung(&self) -> bool {
matches!(
self.werteart,
Werteart::Lastgang | Werteart::Zaehlerstandsgang
)
}
}
#[must_use]
pub fn ueberfaellig_nach_stunden(messprodukt: &str) -> Option<i64> {
let p = self::messprodukt(messprodukt)?;
let frist = p.rhythmus.taegliche_frist()?;
Some(i64::from(24 + frist.hour()))
}
#[must_use]
pub fn business_key(lokations_id: &str, messprodukt: &str) -> String {
format!("{lokations_id}#{}", normalize_code(messprodukt))
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Antwort {
pub antwortcode: String,
#[serde(default)]
pub ebd: Option<String>,
}
impl Antwort {
#[must_use]
pub fn new(antwortcode: impl Into<String>, ebd: Option<String>) -> Self {
Self {
antwortcode: antwortcode.into(),
ebd,
}
}
#[must_use]
pub fn bedeutung(&self) -> Option<&'static str> {
let tree = self.ebd.as_deref()?;
mako_pruefung::codes::lookup(tree, &self.antwortcode).map(|c| c.bedeutung)
}
#[must_use]
pub fn ist_zustimmung(&self) -> Option<bool> {
let tree = self.ebd.as_deref()?;
mako_pruefung::codes::lookup(tree, &self.antwortcode)
.map(|c| c.cluster == mako_pruefung::codes::Cluster::Zustimmung)
}
#[must_use]
pub fn widerspricht_pid(&self, pid_ist_zustimmung: bool) -> bool {
self.ist_zustimmung()
.is_some_and(|zustimmung| zustimmung != pid_ist_zustimmung)
}
#[must_use]
pub fn beschreibung(&self) -> String {
let tree = self.ebd.as_deref().unwrap_or("ohne EBD");
self.bedeutung().map_or_else(
|| {
format!(
"{} ({tree}, im Codelistenkatalog nicht geführt)",
self.antwortcode
)
},
|b| format!("{} ({tree}): {b}", self.antwortcode),
)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Preisposition {
pub artikel_id: String,
pub preistyp: Preistyp,
pub betrag: String,
pub einheit: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Preistyp {
Einrichtung,
Transaktion,
Betrieb,
}
impl Preistyp {
#[must_use]
pub const fn pri_code(self) -> &'static str {
match self {
Self::Einrichtung => "Z01",
Self::Transaktion => "Z02",
Self::Betrieb => "Z03",
}
}
#[must_use]
pub fn from_pri_code(code: &str) -> Option<Self> {
match code {
"Z01" => Some(Self::Einrichtung),
"Z02" => Some(Self::Transaktion),
"Z03" => Some(Self::Betrieb),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Angebot {
#[serde(default)]
pub waehrung: Option<String>,
#[serde(default)]
pub preise: Vec<Preisposition>,
#[serde(default)]
pub obis_kennzahlen: Vec<String>,
#[serde(default)]
#[serde(with = "time::serde::rfc3339::option")]
pub einrichtung_bis: Option<time::OffsetDateTime>,
}
impl Angebot {
#[must_use]
pub fn ist_leer(&self) -> bool {
self.preise.is_empty() && self.obis_kennzahlen.is_empty()
}
#[must_use]
pub fn preis(&self, typ: Preistyp) -> Option<&Preisposition> {
self.preise.iter().find(|p| p.preistyp == typ)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", tag = "art", content = "wert")]
pub enum SmgwQuelle {
Adresse(String),
Range {
von: String,
bis: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Korrelation {
Meldepunkt,
AnfrageNummer,
AngebotsNummer,
AuftragsNummer,
VorherigeNachricht,
BeantragungsNummer,
}
impl Korrelation {
#[must_use]
pub const fn rff_qualifier(self) -> Option<&'static str> {
match self {
Self::Meldepunkt => None,
Self::AnfrageNummer => Some("AAV"),
Self::AngebotsNummer => Some("AAG"),
Self::AuftragsNummer => Some("ON"),
Self::VorherigeNachricht => Some("ACW"),
Self::BeantragungsNummer => Some("AGI"),
}
}
}
#[must_use]
pub const fn korrelation(pid: u32) -> Option<Korrelation> {
match pid {
35003 => Some(Korrelation::Meldepunkt),
15003 => Some(Korrelation::AnfrageNummer),
17007 => Some(Korrelation::AngebotsNummer),
19011 | 19012 | 39002 => Some(Korrelation::AuftragsNummer),
17008 | 19013 | 19014 => Some(Korrelation::VorherigeNachricht),
21042 | 13027 => Some(Korrelation::BeantragungsNummer),
_ => None,
}
}
pub use mako_pruefung::codes::{EBD_ESA_BEENDIGUNG, EBD_ESA_BESTELLUNG, EBD_ESA_STORNIERUNG};
pub use mako_pruefung::esa::wertebestellung::{
Bestellart, EsaBeendigung, EsaBestellung, EsaStornierung, ebd_fuer_antwort, pruefe_beendigung,
pruefe_bestellung, pruefe_stornierung,
};
impl Abonnement {
#[must_use]
pub const fn bestellart(self) -> Bestellart {
match self {
Self::StartAbo | Self::EndeAbo => Bestellart::Abo,
Self::OhneAbo => Bestellart::Einmalig,
}
}
#[must_use]
pub const fn antwort_ebd(self) -> &'static str {
match self {
Self::EndeAbo => EBD_ESA_BEENDIGUNG,
Self::StartAbo | Self::OhneAbo => EBD_ESA_BESTELLUNG,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn messprodukt_codes_are_unique_and_thirteen_digits() {
let mut seen = std::collections::HashSet::new();
for p in BACKEND_PRODUKTE.iter().chain(SMGW_PRODUKTE) {
assert!(seen.insert(p.code), "duplicate Messprodukt-Code {}", p.code);
assert_eq!(p.code.len(), 13, "{} is not 13 digits", p.code);
assert!(p.code.chars().all(|c| c.is_ascii_digit()), "{}", p.code);
}
}
#[test]
fn lookup_accepts_the_spaced_published_form() {
let p = messprodukt("9991 00000 305 6").expect("Pflichtprodukt 305 6");
assert_eq!(p.code, "9991000003056");
assert_eq!(p.ebene, Lokationsebene::Marktlokation);
assert!(p.ist_pflicht_am(time::macros::date!(2026 - 03 - 01)));
assert_eq!(messprodukt("9991000003056").map(|q| q.code), Some(p.code));
}
#[test]
fn unknown_code_is_rejected() {
assert!(messprodukt("9992000000011").is_none());
}
#[test]
fn seven_products_are_pflicht() {
let codes: Vec<_> = pflichtprodukte().map(|p| p.code).collect();
assert_eq!(codes.len(), 7, "{codes:?}");
for expected in [
"9991000003056",
"9991000003064",
"9991000003147",
"9991000000771",
"9991000000789",
"9991000003121",
"9991000003139",
] {
assert!(
codes.contains(&expected),
"{expected} missing from {codes:?}"
);
}
}
fn backend_order() -> Bestellgegenstand {
Bestellgegenstand {
messprodukt: "9991000003056".to_owned(),
wunschtermin: time::macros::date!(2026 - 03 - 01),
zeitraum_bis: None,
abonnement: Abonnement::StartAbo,
smgw: None,
}
}
#[test]
fn backend_order_on_its_own_level_validates() {
assert_eq!(
backend_order().validate(Lokationsebene::Marktlokation),
Ok(())
);
}
#[test]
fn level_mismatch_is_refused() {
assert_eq!(
backend_order().validate(Lokationsebene::Messlokation),
Err(ProduktFehler::EbeneStimmtNicht {
produkt: Lokationsebene::Marktlokation,
anfrage: Lokationsebene::Messlokation,
})
);
}
#[test]
fn wunschtermin_before_nutzbar_ab_is_refused() {
let mut o = backend_order();
o.wunschtermin = time::macros::date!(2024 - 01 - 01);
assert_eq!(
o.validate(Lokationsebene::Marktlokation),
Err(ProduktFehler::NochNichtNutzbar {
nutzbar_ab: time::macros::date!(2024 - 08 - 06),
wunschtermin: time::macros::date!(2024 - 01 - 01),
})
);
}
#[test]
fn smgw_product_without_target_is_refused() {
let o = Bestellgegenstand {
messprodukt: "9991000003121".to_owned(),
..backend_order()
};
assert_eq!(
o.validate(Lokationsebene::Messlokation),
Err(ProduktFehler::SmgwZielFehlt)
);
}
#[test]
fn backend_product_with_smgw_target_is_refused() {
let o = Bestellgegenstand {
smgw: Some(SmgwZiel {
uri_ipv4: "https://192.0.2.1/esa".to_owned(),
uri_ipv6: "https://[2001:db8::1]/esa".to_owned(),
zertifikat_aussteller: "CN=Test-CA".to_owned(),
zertifikat_nutzer: "CN=Test-ESA".to_owned(),
schwellwerte: Vec::new(),
}),
..backend_order()
};
assert_eq!(
o.validate(Lokationsebene::Marktlokation),
Err(ProduktFehler::SmgwZielUnzulaessig)
);
}
#[test]
fn schwellwert_product_without_thresholds_is_refused() {
let o = Bestellgegenstand {
messprodukt: "9991000001216".to_owned(),
smgw: Some(SmgwZiel {
uri_ipv4: "https://192.0.2.1/esa".to_owned(),
uri_ipv6: "https://[2001:db8::1]/esa".to_owned(),
zertifikat_aussteller: "CN=Test-CA".to_owned(),
zertifikat_nutzer: "CN=Test-ESA".to_owned(),
schwellwerte: Vec::new(),
}),
..backend_order()
};
assert_eq!(
o.validate(Lokationsebene::Messlokation),
Err(ProduktFehler::SchwellwertFehlt)
);
}
#[test]
fn the_business_key_separates_products_at_one_location() {
let lastgang = business_key("51238696012", "9991 00000 305 6");
let energiemenge = business_key("51238696012", "9991000003147");
assert_ne!(lastgang, energiemenge);
assert_eq!(lastgang, business_key("51238696012", "9991000003056"));
}
#[test]
fn correlation_keys_match_the_pid_overview() {
for (pid, expected, qual) in [
(35003, Korrelation::Meldepunkt, None),
(15003, Korrelation::AnfrageNummer, Some("AAV")),
(17007, Korrelation::AngebotsNummer, Some("AAG")),
(17008, Korrelation::VorherigeNachricht, Some("ACW")),
(39002, Korrelation::AuftragsNummer, Some("ON")),
(19011, Korrelation::AuftragsNummer, Some("ON")),
(19012, Korrelation::AuftragsNummer, Some("ON")),
(19013, Korrelation::VorherigeNachricht, Some("ACW")),
(19014, Korrelation::VorherigeNachricht, Some("ACW")),
(21042, Korrelation::BeantragungsNummer, Some("AGI")),
(13027, Korrelation::BeantragungsNummer, Some("AGI")),
] {
assert_eq!(korrelation(pid), Some(expected), "PID {pid}");
assert_eq!(expected.rff_qualifier(), qual, "PID {pid}");
}
assert_eq!(korrelation(31009), None, "billing is a different process");
}
#[test]
fn ebd_follows_the_abonnement() {
assert_eq!(Abonnement::StartAbo.antwort_ebd(), EBD_ESA_BESTELLUNG);
assert_eq!(Abonnement::OhneAbo.antwort_ebd(), EBD_ESA_BESTELLUNG);
assert_eq!(Abonnement::EndeAbo.antwort_ebd(), EBD_ESA_BEENDIGUNG);
assert_eq!(
ebd_fuer_antwort(Abonnement::EndeAbo.imd_code()),
Some(EBD_ESA_BEENDIGUNG)
);
}
#[test]
fn the_abo_mode_maps_onto_the_pruefung_bestellart() {
assert_eq!(Abonnement::StartAbo.bestellart(), Bestellart::Abo);
assert_eq!(Abonnement::EndeAbo.bestellart(), Bestellart::Abo);
assert_eq!(Abonnement::OhneAbo.bestellart(), Bestellart::Einmalig);
}
#[test]
fn imd_codes_round_trip() {
for a in [
Abonnement::StartAbo,
Abonnement::EndeAbo,
Abonnement::OhneAbo,
] {
assert_eq!(Abonnement::from_imd_code(a.imd_code()), Some(a));
}
assert_eq!(Abonnement::from_imd_code("Z99"), None);
}
#[test]
fn the_pflicht_is_dated_not_absolute() {
let p = messprodukt("9991 00000 077 1").expect("Pflichtprodukt 077 1");
assert!(!p.ist_pflicht_am(time::macros::date!(2024 - 08 - 05)));
assert!(p.ist_pflicht_am(time::macros::date!(2024 - 08 - 06)));
assert!(p.verbindlichkeit.jemals_pflicht());
let optional = messprodukt("9991 00000 074 7").expect("optional 074 7");
assert!(!optional.ist_pflicht_am(time::macros::date!(2026 - 01 - 01)));
assert!(!optional.verbindlichkeit.jemals_pflicht());
}
#[test]
fn nutzbar_ab_is_not_the_pflicht_date() {
for code in ["9991000000771", "9991000000789"] {
let p = messprodukt(code).expect("in the catalogue");
assert_eq!(
p.nutzbar_ab,
time::macros::date!(2023 - 10 - 01),
"{code}: Codeliste 1.4 Kap. 4.6.1 gives Nutzbar ab 01.10.2023"
);
}
}
#[test]
fn the_product_resolves_the_level_where_the_identifier_cannot() {
assert_eq!(
ebene_fuer_messprodukt("9991 00000 305 6"),
Some(Lokationsebene::Marktlokation)
);
assert_eq!(
ebene_fuer_messprodukt("9991 00000 306 4"),
Some(Lokationsebene::Tranche)
);
assert_eq!(ebene_fuer_messprodukt("9992000000011"), None);
assert!(pflichtprodukte().any(|p| p.ebene == Lokationsebene::Tranche));
}
#[test]
fn an_antwort_resolves_against_the_tree_it_names() {
let ok = Antwort::new("A11", Some(EBD_ESA_BESTELLUNG.to_owned()));
assert_eq!(ok.ist_zustimmung(), Some(true));
assert!(ok.bedeutung().is_some());
assert!(!ok.widerspricht_pid(true));
assert!(ok.widerspricht_pid(false));
let refusal = Antwort::new("A08", Some(EBD_ESA_BESTELLUNG.to_owned()));
assert_eq!(refusal.ist_zustimmung(), Some(false));
assert!(
refusal
.bedeutung()
.expect("A08 is published")
.contains("Einwilligung")
);
}
#[test]
fn an_antwort_without_a_tree_is_not_guessed() {
let bare = Antwort::new("A01", None);
assert_eq!(bare.bedeutung(), None);
assert_eq!(bare.ist_zustimmung(), None);
assert!(
!bare.widerspricht_pid(true),
"unresolvable is not a conflict"
);
assert!(bare.beschreibung().contains("ohne EBD"));
let per_tree: Vec<_> = [EBD_ESA_BESTELLUNG, EBD_ESA_STORNIERUNG, EBD_ESA_BEENDIGUNG]
.into_iter()
.map(|t| Antwort::new("A01", Some(t.to_owned())).bedeutung().unwrap())
.collect();
assert_eq!(
per_tree
.iter()
.collect::<std::collections::HashSet<_>>()
.len(),
3,
"A01 must mean something different in each tree: {per_tree:?}"
);
}
#[test]
fn an_offer_is_told_from_a_refusal_by_its_prices() {
assert!(Angebot::default().ist_leer());
let priced = Angebot {
waehrung: Some("EUR".to_owned()),
preise: vec![Preisposition {
artikel_id: "9990001100002".to_owned(),
preistyp: Preistyp::Betrieb,
betrag: "0.004500".to_owned(),
einheit: "DAY".to_owned(),
}],
obis_kennzahlen: vec!["1-1:1.29.0".to_owned()],
einrichtung_bis: None,
};
assert!(!priced.ist_leer());
assert_eq!(
priced.preis(Preistyp::Betrieb).map(|p| p.betrag.as_str()),
Some("0.004500")
);
assert_eq!(priced.preis(Preistyp::Einrichtung), None);
}
#[test]
fn pri_codes_round_trip() {
for t in [
Preistyp::Einrichtung,
Preistyp::Transaktion,
Preistyp::Betrieb,
] {
assert_eq!(Preistyp::from_pri_code(t.pri_code()), Some(t));
}
assert_eq!(Preistyp::from_pri_code("Z99"), None);
}
#[test]
fn only_rohdaten_products_publish_a_daily_clock() {
let roh = messprodukt("9991000000416").unwrap();
assert_eq!(roh.rhythmus.taegliche_frist(), Some(ROHDATEN_FRIST));
let aufbereitet = messprodukt("9991000003056").unwrap();
assert_eq!(aufbereitet.rhythmus.taegliche_frist(), None);
}
fn ausstattung() -> Geraeteausstattung {
Geraeteausstattung {
ist_ims: true,
hat_verbrauchsregister: true,
hat_erzeugungsregister: true,
hat_blindarbeitsregister: true,
}
}
#[test]
fn a_smgw_product_without_an_ims_cannot_be_served() {
let smgw = messprodukt("9991000003121").expect("SMGW Pflichtprodukt");
let ohne_ims = Geraeteausstattung {
ist_ims: false,
..ausstattung()
};
assert_eq!(
geraetetechnik_geeignet(smgw, Some(&ohne_ims), Some(true)),
Some(false)
);
assert_eq!(
geraetetechnik_geeignet(smgw, Some(&ausstattung()), None),
Some(true),
"an iMS is interval-capable by definition, so the flag is not consulted"
);
}
#[test]
fn a_direction_without_a_register_cannot_be_served() {
let erzeugung = messprodukt("9991000000789").expect("Erzeugung Pflichtprodukt");
let nur_verbrauch = Geraeteausstattung {
hat_erzeugungsregister: false,
..ausstattung()
};
assert_eq!(
geraetetechnik_geeignet(erzeugung, Some(&nur_verbrauch), Some(true)),
Some(false)
);
let verbrauch = messprodukt("9991000000771").expect("Verbrauch Pflichtprodukt");
assert_eq!(
geraetetechnik_geeignet(verbrauch, Some(&nur_verbrauch), Some(true)),
Some(true)
);
}
#[test]
fn blindarbeit_needs_its_own_register() {
let blind = messprodukt("9991000000797").expect("Blindarbeit Q1Q4");
let ohne = Geraeteausstattung {
hat_blindarbeitsregister: false,
..ausstattung()
};
assert_eq!(
geraetetechnik_geeignet(blind, Some(&ohne), Some(true)),
Some(false)
);
}
#[test]
fn what_cannot_be_judged_escalates_rather_than_refusing() {
let lastgang = messprodukt("9991000000771").expect("Lastgang Pflichtprodukt");
assert_eq!(geraetetechnik_geeignet(lastgang, None, Some(true)), None);
let kein_ims = Geraeteausstattung {
ist_ims: false,
..ausstattung()
};
assert_eq!(
geraetetechnik_geeignet(lastgang, Some(&kein_ims), None),
None,
"a ¼h Lastgang on a meter of unknown capability is a question, not a refusal"
);
assert_eq!(
geraetetechnik_geeignet(lastgang, Some(&kein_ims), Some(false)),
Some(false),
"…and once the caller says the meter registers no intervals, it is an answer"
);
assert_eq!(
geraetetechnik_geeignet(lastgang, Some(&kein_ims), Some(true)),
Some(true),
"an RLM meter serves a Backend Lastgang without an iMS"
);
}
#[test]
fn an_arbeitsmenge_needs_no_interval_register() {
let menge = messprodukt("9991000003147").expect("Energiemenge Pflichtprodukt");
assert!(!menge.braucht_intervallmessung());
let kein_ims = Geraeteausstattung {
ist_ims: false,
..ausstattung()
};
assert_eq!(
geraetetechnik_geeignet(menge, Some(&kein_ims), None),
Some(true)
);
}
#[test]
fn the_silence_threshold_comes_from_the_ordered_product() {
assert_eq!(ueberfaellig_nach_stunden("9991000000416"), Some(33));
assert_eq!(ueberfaellig_nach_stunden("9991 00000 305 6"), None);
assert_eq!(ueberfaellig_nach_stunden("9991000001183"), None);
assert_eq!(ueberfaellig_nach_stunden("9992000000011"), None);
}
#[test]
fn every_product_with_a_clock_yields_a_threshold_and_no_other_does() {
for p in BACKEND_PRODUKTE.iter().chain(SMGW_PRODUKTE) {
let hours = ueberfaellig_nach_stunden(p.code);
match p.rhythmus.taegliche_frist() {
Some(frist) => assert_eq!(
hours,
Some(i64::from(24 + frist.hour())),
"{}: a published clock must size the threshold",
p.code
),
None => assert_eq!(
hours, None,
"{}: WiM Kap. 2.5.5 states no clock — do not invent one",
p.code
),
}
}
assert_eq!(ROHDATEN_FRIST.hour(), 9);
}
}