use std::fmt;
use crate::{
message::DvgwMessage,
model::{LineItem, nad, rff},
pruefidentifikator::Pruefidentifikator,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Zuordnung {
ZoT1,
ZoT2,
ZoT3,
ZoT4,
ZgT1,
MehrMindermengen,
Nominierung,
}
impl Zuordnung {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ZoT1 => "ZO-T1",
Self::ZoT2 => "ZO-T2",
Self::ZoT3 => "ZO-T3",
Self::ZoT4 => "ZO-T4",
Self::ZgT1 => "ZG-T1",
Self::MehrMindermengen => "ZO-T1:SSQNOT",
Self::Nominierung => "Nominierung",
}
}
#[must_use]
pub fn assigns_to_geschaeftsvorfall(self) -> bool {
matches!(self, Self::ZgT1)
}
#[must_use]
pub fn scopes_its_own_period(self) -> bool {
matches!(self, Self::ZgT1 | Self::Nominierung)
}
#[must_use]
pub fn for_pid(pid: Pruefidentifikator) -> Option<Self> {
let zuordnung = match pid.as_u32() {
70001 | 70004..=70007 | 70022 => Self::ZoT3,
70002 | 70003 | 70011 | 70012 | 70023 => Self::ZoT2,
70013..=70017 | 70021 => Self::ZoT1,
70008..=70010 | 70018..=70020 => Self::ZgT1,
70030..=70039 => Self::Nominierung,
70095 | 70096 => Self::MehrMindermengen,
_ => return None,
};
Some(zuordnung)
}
}
impl fmt::Display for Zuordnung {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct CorrelationKey {
pub zuordnung: Zuordnung,
pub elements: Vec<String>,
}
impl CorrelationKey {
#[must_use]
pub fn nominierung(
gas_day: time::Date,
ort: &str,
bilanzkreis_intern: &str,
bilanzkreis_extern: &str,
) -> Self {
Self {
zuordnung: Zuordnung::Nominierung,
elements: vec![
gas_day.to_string(),
ort.to_owned(),
bilanzkreis_intern.to_owned(),
bilanzkreis_extern.to_owned(),
],
}
}
#[must_use]
pub fn is_complete(&self) -> bool {
!self.elements.is_empty() && self.elements.iter().all(|e| !e.is_empty())
}
}
impl fmt::Display for CorrelationKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.zuordnung.as_str())?;
for element in &self.elements {
write!(f, "|{element}")?;
}
Ok(())
}
}
impl DvgwMessage {
#[must_use]
pub fn correlation_key(&self) -> Option<CorrelationKey> {
let zuordnung = Zuordnung::for_pid(self.pruefidentifikator?)?;
let item = self.items.first();
let item_party = |role: &str| {
item.and_then(|i| i.party(role))
.map(|p| p.id.clone())
.unwrap_or_default()
};
let zeitreihentyp = || {
item.and_then(LineItem::status_code)
.map(str::to_owned)
.unwrap_or_default()
};
let gas_day = || {
self.validity_period
.map(|p| p.start.date().to_string())
.unwrap_or_default()
};
let elements = match zuordnung {
Zuordnung::ZoT1 => vec![
item_party(nad::BILANZKREIS_INTERN),
item_party(nad::NETZBETREIBER),
zeitreihentyp(),
],
Zuordnung::ZoT2 => vec![
self.sender().map(|p| p.id.clone()).unwrap_or_default(),
item_party(nad::VORGELAGERTER_NETZBETREIBER),
item_party(nad::NETZKONTO),
],
Zuordnung::ZoT3 => vec![
item_party(nad::BILANZKREIS_INTERN),
item_party(nad::NETZKONTO_ZO_T3),
zeitreihentyp(),
],
Zuordnung::ZoT4 => vec![
item_party(nad::BILANZKREIS_INTERN),
item_party(nad::VIRTUELLER_HANDELSPUNKT),
zeitreihentyp(),
],
Zuordnung::ZgT1 => vec![
self.reference(rff::CLEARINGNUMMER)
.unwrap_or_default()
.to_owned(),
],
Zuordnung::MehrMindermengen => vec![
item_party(nad::NETZKONTO_ZO_T3),
self.sender().map(|p| p.id.clone()).unwrap_or_default(),
],
Zuordnung::Nominierung => vec![
gas_day(),
item.and_then(|i| i.locations.first())
.and_then(|l| l.code.clone())
.unwrap_or_default(),
item_party(nad::BILANZKREIS_INTERN),
item_party(nad::BILANZKREIS_EXTERN),
],
};
Some(CorrelationKey {
zuordnung,
elements,
})
}
#[must_use]
pub fn gas_day(&self) -> Option<time::Date> {
self.validity_period.map(|p| p.start.date())
}
#[must_use]
pub fn process_key(&self) -> Option<String> {
let key = self.correlation_key()?;
if key.zuordnung.scopes_its_own_period() {
return Some(key.to_string());
}
if key.zuordnung == Zuordnung::MehrMindermengen {
let period = self.validity_period?;
return Some(format!(
"{key}|{}..{}",
period.start.date(),
period.end.date()
));
}
let gas_day = self.gas_day()?;
Some(format!("{key}|{gas_day}"))
}
}
pub fn assigned_pids() -> impl Iterator<Item = (Pruefidentifikator, Zuordnung)> {
crate::pruefidentifikator::catalogue()
.iter()
.filter_map(|info| {
let pid = Pruefidentifikator::new(info.pid)?;
Zuordnung::for_pid(pid).map(|z| (pid, z))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_catalogued_pid_has_a_zuordnung() {
let catalogued = crate::pruefidentifikator::catalogue().len();
assert_eq!(
assigned_pids().count(),
catalogued,
"a catalogued PID has no Zuordnung assignment"
);
}
#[test]
fn the_assignments_match_the_published_table() {
let z = |pid: u32| Zuordnung::for_pid(Pruefidentifikator::new(pid).unwrap()).unwrap();
for pid in [70001, 70004, 70005, 70006, 70007] {
assert_eq!(z(pid), Zuordnung::ZoT3, "{pid}");
}
for pid in [70002, 70003, 70011, 70012, 70023] {
assert_eq!(z(pid), Zuordnung::ZoT2, "{pid}");
}
for pid in [70013, 70014, 70015, 70016, 70017, 70021] {
assert_eq!(z(pid), Zuordnung::ZoT1, "{pid}");
}
assert_eq!(z(70022), Zuordnung::ZoT3);
for pid in [70008, 70009, 70010, 70018, 70019, 70020] {
assert_eq!(z(pid), Zuordnung::ZgT1, "{pid}");
assert!(z(pid).assigns_to_geschaeftsvorfall(), "{pid}");
}
for pid in 70030..=70039 {
assert_eq!(z(pid), Zuordnung::Nominierung, "{pid}");
}
for pid in [70095, 70096] {
assert_eq!(z(pid), Zuordnung::MehrMindermengen, "{pid}");
}
assert_eq!(
Zuordnung::for_pid(Pruefidentifikator::new(70500).unwrap()),
None
);
}
#[test]
fn the_rendered_key_carries_its_tuple() {
let key = CorrelationKey {
zuordnung: Zuordnung::ZoT1,
elements: vec!["BK1".into(), "NB1".into(), "Z01".into()],
};
assert_eq!(key.to_string(), "ZO-T1|BK1|NB1|Z01");
assert!(key.is_complete());
let same_values_other_tuple = CorrelationKey {
zuordnung: Zuordnung::ZoT3,
elements: vec!["BK1".into(), "NB1".into(), "Z01".into()],
};
assert_ne!(key.to_string(), same_values_other_tuple.to_string());
}
#[test]
fn an_absent_element_keeps_its_slot() {
let key = CorrelationKey {
zuordnung: Zuordnung::ZoT1,
elements: vec!["BK1".into(), String::new(), "Z01".into()],
};
assert_eq!(key.to_string(), "ZO-T1|BK1||Z01");
assert!(!key.is_complete());
assert_ne!(
key.to_string(),
CorrelationKey {
zuordnung: Zuordnung::ZoT1,
elements: vec!["BK1".into(), "Z01".into()],
}
.to_string()
);
}
}
#[cfg(test)]
mod process_key_tests {
use crate::{DvgwDocument, DvgwPeriod, DvgwPlatform, MessageBuilder, Position, model::nad};
use time::macros::datetime;
fn alocat(pid: u32, day: u8, clearing: &str) -> Vec<u8> {
let gas_day = DvgwPeriod {
start: datetime!(2026-03-01 05:00 UTC) + time::Duration::days(i64::from(day)),
end: datetime!(2026-03-02 05:00 UTC) + time::Duration::days(i64::from(day)),
};
MessageBuilder::new(DvgwDocument::AllokationSlp)
.document_number("ALOCAT1")
.version("5.11a")
.pruefidentifikator(pid)
.message_datetime(datetime!(2026-03-01 04:00 UTC))
.validity_period(gas_day)
.clearingnummer(clearing)
.sender("A")
.receiver("B")
.position(
Position::new()
.item_type("Z01")
.location("Z99", None)
.quantity("Z03", "4000", gas_day)
.status("09G")
.party(nad::BILANZKREIS_INTERN, "BK1")
.party(nad::NETZKONTO_ZO_T3, "NK1"),
)
.build()
.expect("builds")
}
#[test]
fn two_gas_days_of_one_object_are_two_processes() {
let platform = DvgwPlatform::default();
let day_one = platform.parse(&alocat(70_001, 0, "CLR-A")).unwrap();
let day_two = platform.parse(&alocat(70_001, 1, "CLR-A")).unwrap();
assert_eq!(day_one.correlation_key(), day_two.correlation_key());
assert_ne!(day_one.process_key(), day_two.process_key());
assert_eq!(
day_one.process_key().as_deref(),
Some("ZO-T3|BK1|NK1|09G|2026-03-01")
);
}
#[test]
fn a_clearing_case_is_not_split_by_gas_day() {
let platform = DvgwPlatform::default();
let day_one = platform.parse(&alocat(70_008, 0, "CLR-A")).unwrap();
let day_two = platform.parse(&alocat(70_008, 1, "CLR-A")).unwrap();
assert_eq!(day_one.process_key(), day_two.process_key());
assert_eq!(day_one.process_key().as_deref(), Some("ZG-T1|CLR-A"));
let other = platform.parse(&alocat(70_008, 0, "CLR-B")).unwrap();
assert_ne!(day_one.process_key(), other.process_key());
}
#[test]
fn a_missing_gas_day_yields_no_process_key() {
let mut wire = String::from_utf8(alocat(70_001, 0, "CLR-A")).unwrap();
wire = wire.replace("DTM+Z01:202603010500202603020500:719'", "");
let msg = DvgwPlatform::default().parse(wire.as_bytes()).unwrap();
assert!(msg.correlation_key().is_some(), "the tuple still resolves");
assert_eq!(msg.process_key(), None, "but the process does not");
}
}