use rust_decimal::Decimal;
use crate::datetime::DvgwPeriod;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct Party {
pub role: String,
pub id: String,
pub agency: Option<String>,
}
impl Party {
#[must_use]
pub fn is_dvgw_coded(&self) -> bool {
self.agency.as_deref() == Some(crate::document::DVGW_AGENCY_CODE)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct Reference {
pub qualifier: String,
pub value: String,
}
pub mod rff {
pub const PRUEFIDENTIFIKATOR: &str = "Z13";
pub const CLEARINGNUMMER: &str = "ANX";
pub const ORIGINAL_NOMINIERUNG: &str = "AGO";
}
pub mod qty {
pub const EINSPEISUNG: &str = "Z02";
pub const AUSSPEISUNG: &str = "Z03";
pub const MEHRMENGE: &str = "ZY0";
pub const MINDERMENGE: &str = "ZY2";
}
pub mod unit {
pub const KWH_PER_HOUR: &str = "KW1";
pub const KWH_PER_DAY: &str = "KW2";
pub const KWH: &str = "KWH";
}
pub mod sts {
pub const SLP: &str = "A1G";
pub const RLM: &str = "A2G";
pub const SLP_SYNTHETISCH: &str = "09G";
pub const RLM_TAGESREGIME: &str = "14G";
pub const SLP_ANALYTISCH: &str = "15G";
pub const RLM_STUNDENREGIME: &str = "18G";
}
pub mod nad {
pub const ABSENDER: &str = "MS";
pub const EMPFAENGER: &str = "MR";
pub const ZUSAETZLICHER_BKV: &str = "ZSY";
pub const BILANZKREIS_INTERN: &str = "ZEU";
pub const BILANZKREIS_EXTERN: &str = "ZES";
pub const NETZKONTO: &str = "ZSZ";
pub const NETZBETREIBER: &str = "ZSO";
pub const NETZKONTO_ZO_T3: &str = "ZSH";
pub const VORGELAGERTER_NETZBETREIBER: &str = "ZET";
pub const VIRTUELLER_HANDELSPUNKT: &str = "VHP";
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct Quantity {
pub qualifier: String,
pub value: Option<Decimal>,
pub raw_value: String,
pub unit: Option<String>,
pub period: Option<DvgwPeriod>,
pub status: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct LocationGroup {
pub qualifier: String,
pub code: Option<String>,
pub agency: Option<String>,
pub quantities: Vec<Quantity>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct ItemDescription {
pub characteristic: Option<String>,
pub code: Option<String>,
}
pub mod imd {
pub const AKZEPTIERT_NB: &str = "12G";
pub const AKZEPTIERT_NACHBAR_NB: &str = "13G";
pub const VERARBEITET_NB: &str = "14G";
pub const VERARBEITET_NACHBAR_NB: &str = "15G";
pub const GEMATCHT: &str = "16G";
pub const NOMINIERT: &str = "17G";
pub const GEGENSEITE: &str = "18G";
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct LineItem {
pub number: Option<String>,
pub item_type: Option<String>,
pub descriptions: Vec<ItemDescription>,
pub locations: Vec<LocationGroup>,
pub parties: Vec<Party>,
}
impl Quantity {
#[must_use]
pub fn energy_kwh(&self) -> Option<Decimal> {
let value = self.value?;
let per_seconds = match self.unit.as_deref() {
Some(unit::KWH) => return Some(value),
Some(unit::KWH_PER_HOUR) => Decimal::from(3600),
Some(unit::KWH_PER_DAY) => Decimal::from(86_400),
_ => return None,
};
let period = self.period?;
let seconds = Decimal::from(period.duration().whole_seconds());
if seconds <= Decimal::ZERO {
return None;
}
Some(value * seconds / per_seconds)
}
#[must_use]
pub fn status_code(&self) -> Option<&str> {
self.status.first().map(String::as_str)
}
}
impl LineItem {
#[must_use]
pub fn party(&self, role: &str) -> Option<&Party> {
self.parties.iter().find(|p| p.role == role)
}
pub fn quantities(&self) -> impl Iterator<Item = &Quantity> {
self.locations.iter().flat_map(|l| l.quantities.iter())
}
#[must_use]
pub fn description_code(&self) -> Option<&str> {
self.descriptions.iter().find_map(|d| d.code.as_deref())
}
#[must_use]
pub fn status_code(&self) -> Option<&str> {
self.quantities().find_map(Quantity::status_code)
}
}
pub type EnergyByQualifier = std::collections::BTreeMap<String, Decimal>;
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::prelude::FromPrimitive;
fn qty(value: i64) -> Quantity {
Quantity {
qualifier: "Z03".into(),
value: Decimal::from_i64(value),
raw_value: value.to_string(),
unit: Some("KW1".into()),
period: None,
status: Vec::new(),
}
}
#[test]
fn energy_follows_the_unit() {
use crate::datetime::DvgwPeriod;
use time::macros::datetime;
let day = DvgwPeriod {
start: datetime!(2026-03-01 05:00 UTC),
end: datetime!(2026-03-02 05:00 UTC),
};
let q = |unit: &str| Quantity {
qualifier: "Z03".into(),
value: Decimal::from_i64(100),
raw_value: "100".into(),
unit: Some(unit.into()),
period: Some(day),
status: Vec::new(),
};
assert_eq!(q("KW1").energy_kwh().unwrap().to_string(), "2400");
assert_eq!(q("KW2").energy_kwh().unwrap().to_string(), "100");
assert_eq!(q("KWH").energy_kwh().unwrap().to_string(), "100");
assert_eq!(
q("MWH").energy_kwh(),
None,
"an unknown unit is not guessed"
);
}
#[test]
fn a_position_flattens_quantities_across_its_location_groups() {
let item = LineItem {
number: Some("1".into()),
item_type: Some("Z01".into()),
descriptions: Vec::new(),
locations: vec![
LocationGroup {
qualifier: "Z99".into(),
code: None,
agency: None,
quantities: vec![qty(100), qty(200)],
},
LocationGroup {
qualifier: "Z19".into(),
code: Some("ABCD1234".into()),
agency: Some("332".into()),
quantities: vec![qty(300)],
},
],
parties: vec![Party {
role: nad::BILANZKREIS_INTERN.into(),
id: "THE0BFH000000001".into(),
agency: Some("332".into()),
}],
};
assert_eq!(item.quantities().count(), 3, "the time series must survive");
assert_eq!(
item.party(nad::BILANZKREIS_INTERN).unwrap().id,
"THE0BFH000000001"
);
assert!(item.party(nad::BILANZKREIS_EXTERN).is_none());
assert!(item.parties[0].is_dvgw_coded());
}
}