Skip to main content

dvgw_edi/
zuordnung.rs

1//! How a received message finds the object or process it belongs to.
2//!
3//! ALOCAT 5.11a §3.3 publishes this per Prüfidentifikator — which
4//! *Zuordnungstupel* the receiver applies, and the exact segments each element
5//! comes from:
6//!
7//! | Tuple | Elements | Segments |
8//! |---|---|---|
9//! | `ZO-T1` | Bilanzkreis, Netzbetreiber, Zeitreihentyp | `SG39 NAD+ZEU`, `SG39 NAD+ZSO`, `SG36 SG37 STS` |
10//! | `ZO-T2` | Verantwortlicher Absender, vorgelagerter NB, nachgelagerter NB | `SG3 NAD+MS`, `SG39 NAD+ZET`, `SG39 NAD+ZSZ` |
11//! | `ZO-T3` | Bilanzkreis, Netzkontonummer, Zeitreihentyp | `SG39 NAD+ZEU`, `SG39 NAD+ZSH`, `SG36 SG37 STS` |
12//! | `ZO-T4` | Bilanzkreis, Virtueller Handelspunkt, Zeitreihentyp | `SG39 NAD+ZEU`, `SG39 NAD+VHP`, `SG36 SG37 STS` |
13//! | `ZG-T1` | Clearingnummer | `SG1 RFF+ANX` |
14//!
15//! The Zeitreihentyp is the `STS` DE 9015 code under the quantity (`09G` SLP
16//! synthetisch, `14G` RLM Tagesregime, …) — not `LIN` C212 DE 7143, which is
17//! `Z01` „allokiert" on every ALOCAT position.
18//!
19//! SSQNOT 5.7 §3.3 publishes its own tuple, also named `ZO-T1` there:
20//!
21//! | Tuple | Elements | Segments |
22//! |---|---|---|
23//! | `ZO-T1:SSQNOT` | Netzkonto, Netzbetreiber | `SG39 NAD+ZSH`, `SG3 NAD+MS` |
24//!
25//! `ZO-T*` assigns the message to an **object**, `ZG-T1` to an existing
26//! **Geschäftsvorfall** (an open Clearingfall) — keying both the same way merges
27//! a clearing correction into the stream it corrects.
28//!
29//! Nominations carry no published tuple: a NOMRES has one `RFF`, and it is the
30//! Prüfidentifikator, so a NOMRES cannot be paired with its NOMINT by reference
31//! — only by the business key both carry.
32
33use std::fmt;
34
35use crate::{
36    message::DvgwMessage,
37    model::{LineItem, nad, rff},
38    pruefidentifikator::Pruefidentifikator,
39};
40
41/// The Zuordnungstupel a Prüfidentifikator is assigned.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub enum Zuordnung {
45    /// `ZO-T1` — (Bilanzkreis, Netzbetreiber, Zeitreihentyp).
46    ZoT1,
47    /// `ZO-T2` — (Verantwortlicher Absender, vorgelagerter NB, nachgelagerter NB).
48    ZoT2,
49    /// `ZO-T3` — (Bilanzkreis, Netzkontonummer, Zeitreihentyp).
50    ZoT3,
51    /// `ZO-T4` — (Bilanzkreis, Virtueller Handelspunkt, Zeitreihentyp).
52    ZoT4,
53    /// `ZG-T1` — (Clearingnummer). Assigns to an open Geschäftsvorfall.
54    ZgT1,
55    /// SSQNOT `ZO-T1` — (Netzkonto, Netzbetreiber): the 2-Tupel der
56    /// Mehr-/Mindermengenmeldung Gas (SSQNOT 5.7 §3.3). Labelled apart from
57    /// ALOCAT's `ZO-T1`, which is a different tuple under the same name.
58    MehrMindermengen,
59    /// Nomination pairing: (Gastag, Ort, Bilanzkreis intern, Bilanzkreis extern).
60    ///
61    /// Not a DVGW-published tuple — NOMINT/NOMRES publish none, because a NOMRES
62    /// carries no reference to the nomination it answers. This is the business
63    /// key both messages do carry, and it is the only thing that pairs them.
64    Nominierung,
65}
66
67impl Zuordnung {
68    /// The published label, or `"Nominierung"` for the derived nomination key.
69    #[must_use]
70    pub fn as_str(self) -> &'static str {
71        match self {
72            Self::ZoT1 => "ZO-T1",
73            Self::ZoT2 => "ZO-T2",
74            Self::ZoT3 => "ZO-T3",
75            Self::ZoT4 => "ZO-T4",
76            Self::ZgT1 => "ZG-T1",
77            Self::MehrMindermengen => "ZO-T1:SSQNOT",
78            Self::Nominierung => "Nominierung",
79        }
80    }
81
82    /// `true` when the tuple assigns to an existing Geschäftsvorfall rather than
83    /// to an object — i.e. the message continues a case instead of extending a
84    /// stream.
85    #[must_use]
86    pub fn assigns_to_geschaeftsvorfall(self) -> bool {
87        matches!(self, Self::ZgT1)
88    }
89
90    /// `true` when the tuple already names the period it belongs to, so a
91    /// process key needs nothing added: a Clearingnummer identifies one case,
92    /// and the nomination key carries the gas day as its first element.
93    #[must_use]
94    pub fn scopes_its_own_period(self) -> bool {
95        matches!(self, Self::ZgT1 | Self::Nominierung)
96    }
97
98    /// The tuple DVGW assigns to a Prüfidentifikator.
99    ///
100    /// Source: ALOCAT 5.11a §3.3, SSQNOT 5.7 §3.3. Returns `None` for a code
101    /// with no published assignment — including any ALOCAT code outside the
102    /// shipped package, which must not be guessed at.
103    #[must_use]
104    pub fn for_pid(pid: Pruefidentifikator) -> Option<Self> {
105        let zuordnung = match pid.as_u32() {
106            // Allokationsabgabe (NB an MGV, 70001/70004–70007) and the optional
107            // tägliche SLP-Allokation (NB an BKV, 70022).
108            70001 | 70004..=70007 | 70022 => Self::ZoT3,
109            // Allokationsabgabe NKP — NB an MGV (70002/70003), ENB/ANB an NB
110            // (70011/70012), MGV an NB (70023).
111            70002 | 70003 | 70011 | 70012 | 70023 => Self::ZoT2,
112            // Allokationsabgabe (MGV an BKV, 70013–70017) and Ersatzwertversand
113            // (MGV an NB, 70021).
114            //
115            // The published row for 70013–70017 names ZO-T1 *and* ZO-T4; they
116            // differ only in whether the counterparty is a Netzbetreiber or the
117            // Virtueller Handelspunkt, which the message states by which `NAD`
118            // role it carries rather than by its Prüfidentifikator.
119            70013..=70017 | 70021 => Self::ZoT1,
120            // Allokationsabgabe Clearing — assigns to an open Clearingfall.
121            70008..=70010 | 70018..=70020 => Self::ZgT1,
122            // NOMINT / NOMRES.
123            70030..=70039 => Self::Nominierung,
124            // SSQNOT — Mehr-/Mindermengenmeldung SLP / RLM.
125            70095 | 70096 => Self::MehrMindermengen,
126            _ => return None,
127        };
128        Some(zuordnung)
129    }
130}
131
132impl fmt::Display for Zuordnung {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        f.write_str(self.as_str())
135    }
136}
137
138/// A resolved Zuordnungstupel — the tuple and the values read for it.
139#[derive(Debug, Clone, PartialEq, Eq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize))]
141#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
142pub struct CorrelationKey {
143    /// Which tuple was applied.
144    pub zuordnung: Zuordnung,
145    /// The tuple's elements, in the order the specification lists them.
146    ///
147    /// An element the message did not carry is an empty string rather than a
148    /// dropped position, so two keys never collide by shifting.
149    pub elements: Vec<String>,
150}
151
152impl CorrelationKey {
153    /// The nomination key both ends build: (Gastag, Ort, Bilanzkreis intern,
154    /// Bilanzkreis extern).
155    ///
156    /// NOMINT and NOMRES publish no Zuordnungstupel — a NOMRES carries no
157    /// reference to the nomination it answers — so the pair meets on this
158    /// business key. A sender that assembles it by hand and a receiver that
159    /// reads it off the wire have to agree character for character, which is
160    /// why both call this.
161    #[must_use]
162    pub fn nominierung(
163        gas_day: time::Date,
164        ort: &str,
165        bilanzkreis_intern: &str,
166        bilanzkreis_extern: &str,
167    ) -> Self {
168        Self {
169            zuordnung: Zuordnung::Nominierung,
170            elements: vec![
171                gas_day.to_string(),
172                ort.to_owned(),
173                bilanzkreis_intern.to_owned(),
174                bilanzkreis_extern.to_owned(),
175            ],
176        }
177    }
178
179    /// `true` when every element carries a value.
180    ///
181    /// A partial key still identifies *something*, but on fewer facts than DVGW
182    /// specified.
183    #[must_use]
184    pub fn is_complete(&self) -> bool {
185        !self.elements.is_empty() && self.elements.iter().all(|e| !e.is_empty())
186    }
187}
188
189impl fmt::Display for CorrelationKey {
190    /// A stable, flat rendering for use as a process-registry key.
191    ///
192    /// The tuple label is part of the string: the same Bilanzkreis under `ZO-T1`
193    /// and `ZO-T3` names two different objects — a Netzbetreiber's stream and a
194    /// Netzkonto's.
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        write!(f, "{}", self.zuordnung.as_str())?;
197        for element in &self.elements {
198            write!(f, "|{element}")?;
199        }
200        Ok(())
201    }
202}
203
204impl DvgwMessage {
205    /// The Zuordnungstupel this message is assigned by, with its values read.
206    ///
207    /// Returns `None` when the Prüfidentifikator is absent or has no published
208    /// assignment — the message then has no defined way to reach a process, and
209    /// inventing one would attach it to the wrong stream.
210    #[must_use]
211    pub fn correlation_key(&self) -> Option<CorrelationKey> {
212        let zuordnung = Zuordnung::for_pid(self.pruefidentifikator?)?;
213        // Every ZO-T* element outside the header is read from the first position:
214        // the tuple identifies the message, and a conformant message states one
215        // object per message.
216        let item = self.items.first();
217        let item_party = |role: &str| {
218            item.and_then(|i| i.party(role))
219                .map(|p| p.id.clone())
220                .unwrap_or_default()
221        };
222        // `SG36 SG37 STS` — the Zeitreihentyp is the status code under the
223        // quantity, not `LIN` C212 DE 7143 (which is always `Z01` „allokiert").
224        let zeitreihentyp = || {
225            item.and_then(LineItem::status_code)
226                .map(str::to_owned)
227                .unwrap_or_default()
228        };
229        let gas_day = || {
230            self.validity_period
231                .map(|p| p.start.date().to_string())
232                .unwrap_or_default()
233        };
234
235        let elements = match zuordnung {
236            Zuordnung::ZoT1 => vec![
237                item_party(nad::BILANZKREIS_INTERN),
238                item_party(nad::NETZBETREIBER),
239                zeitreihentyp(),
240            ],
241            Zuordnung::ZoT2 => vec![
242                self.sender().map(|p| p.id.clone()).unwrap_or_default(),
243                item_party(nad::VORGELAGERTER_NETZBETREIBER),
244                item_party(nad::NETZKONTO),
245            ],
246            Zuordnung::ZoT3 => vec![
247                item_party(nad::BILANZKREIS_INTERN),
248                item_party(nad::NETZKONTO_ZO_T3),
249                zeitreihentyp(),
250            ],
251            Zuordnung::ZoT4 => vec![
252                item_party(nad::BILANZKREIS_INTERN),
253                item_party(nad::VIRTUELLER_HANDELSPUNKT),
254                zeitreihentyp(),
255            ],
256            Zuordnung::ZgT1 => vec![
257                self.reference(rff::CLEARINGNUMMER)
258                    .unwrap_or_default()
259                    .to_owned(),
260            ],
261            Zuordnung::MehrMindermengen => vec![
262                item_party(nad::NETZKONTO_ZO_T3),
263                self.sender().map(|p| p.id.clone()).unwrap_or_default(),
264            ],
265            Zuordnung::Nominierung => vec![
266                gas_day(),
267                item.and_then(|i| i.locations.first())
268                    .and_then(|l| l.code.clone())
269                    .unwrap_or_default(),
270                item_party(nad::BILANZKREIS_INTERN),
271                item_party(nad::BILANZKREIS_EXTERN),
272            ],
273        };
274        Some(CorrelationKey {
275            zuordnung,
276            elements,
277        })
278    }
279
280    /// The gas day this message reports on, as `YYYY-MM-DD`.
281    ///
282    /// Read from `DTM+Z01`, never from `DTM+137`.
283    #[must_use]
284    pub fn gas_day(&self) -> Option<time::Date> {
285        self.validity_period.map(|p| p.start.date())
286    }
287
288    /// The key identifying the *process* this message belongs to.
289    ///
290    /// The [`correlation_key`](Self::correlation_key) plus the period the
291    /// published tuples leave out: a `ZO-T*` tuple identifies an **object** —
292    /// an account, not one day of it — while a process is one gas day of that
293    /// object, holding that day's record and its `KoV` §6.4 deadline. A
294    /// Mehr-/Mindermengenmeldung reports an Abrechnungszeitraum rather than a
295    /// gas day, so its key carries the whole `DTM+Z01` period.
296    ///
297    /// A tuple that already names its period is returned unchanged
298    /// ([`Zuordnung::scopes_its_own_period`]): a Clearingnummer identifies one
299    /// Geschäftsvorfall, which may span several days, and the nomination key
300    /// carries the gas day as its first element.
301    ///
302    /// Returns `None` when the message has no published Zuordnung, or when a
303    /// tuple that needs a period has none to read.
304    #[must_use]
305    pub fn process_key(&self) -> Option<String> {
306        let key = self.correlation_key()?;
307        if key.zuordnung.scopes_its_own_period() {
308            return Some(key.to_string());
309        }
310        if key.zuordnung == Zuordnung::MehrMindermengen {
311            let period = self.validity_period?;
312            return Some(format!(
313                "{key}|{}..{}",
314                period.start.date(),
315                period.end.date()
316            ));
317        }
318        let gas_day = self.gas_day()?;
319        Some(format!("{key}|{gas_day}"))
320    }
321}
322
323/// Every Prüfidentifikator the shipped catalogue assigns a Zuordnung.
324///
325/// Used by the routing layer to refuse, at startup, to register a PID it has no
326/// defined way to correlate.
327pub fn assigned_pids() -> impl Iterator<Item = (Pruefidentifikator, Zuordnung)> {
328    crate::pruefidentifikator::catalogue()
329        .iter()
330        .filter_map(|info| {
331            let pid = Pruefidentifikator::new(info.pid)?;
332            Zuordnung::for_pid(pid).map(|z| (pid, z))
333        })
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    /// Every catalogued Prüfidentifikator must have a published Zuordnung, or a
341    /// message carrying it has no defined way to reach a process.
342    #[test]
343    fn every_catalogued_pid_has_a_zuordnung() {
344        let catalogued = crate::pruefidentifikator::catalogue().len();
345        assert_eq!(
346            assigned_pids().count(),
347            catalogued,
348            "a catalogued PID has no Zuordnung assignment"
349        );
350    }
351
352    /// The assignments must match ALOCAT 5.11a §3.3 exactly.
353    #[test]
354    fn the_assignments_match_the_published_table() {
355        let z = |pid: u32| Zuordnung::for_pid(Pruefidentifikator::new(pid).unwrap()).unwrap();
356
357        // Allokationsabgabe (NB an MGV) — ZO-T3.
358        for pid in [70001, 70004, 70005, 70006, 70007] {
359            assert_eq!(z(pid), Zuordnung::ZoT3, "{pid}");
360        }
361        // Allokationsabgabe NKP — ZO-T2.
362        for pid in [70002, 70003, 70011, 70012, 70023] {
363            assert_eq!(z(pid), Zuordnung::ZoT2, "{pid}");
364        }
365        // Allokationsabgabe (MGV an BKV) and Ersatzwertversand — ZO-T1.
366        for pid in [70013, 70014, 70015, 70016, 70017, 70021] {
367            assert_eq!(z(pid), Zuordnung::ZoT1, "{pid}");
368        }
369        // Optional tägliche SLP-Allokation — ZO-T3.
370        assert_eq!(z(70022), Zuordnung::ZoT3);
371        // Clearing — assigns to a Geschäftsvorfall, not an object.
372        for pid in [70008, 70009, 70010, 70018, 70019, 70020] {
373            assert_eq!(z(pid), Zuordnung::ZgT1, "{pid}");
374            assert!(z(pid).assigns_to_geschaeftsvorfall(), "{pid}");
375        }
376        // Nominations pair on the business key.
377        for pid in 70030..=70039 {
378            assert_eq!(z(pid), Zuordnung::Nominierung, "{pid}");
379        }
380        // Mehr-/Mindermengen — SSQNOT 5.7 §3.3.
381        for pid in [70095, 70096] {
382            assert_eq!(z(pid), Zuordnung::MehrMindermengen, "{pid}");
383        }
384        // An uncatalogued code in range has no assignment to guess at.
385        assert_eq!(
386            Zuordnung::for_pid(Pruefidentifikator::new(70500).unwrap()),
387            None
388        );
389    }
390
391    /// The tuple label has to be part of the key: the same Bilanzkreis under two
392    /// different tuples is two different objects.
393    #[test]
394    fn the_rendered_key_carries_its_tuple() {
395        let key = CorrelationKey {
396            zuordnung: Zuordnung::ZoT1,
397            elements: vec!["BK1".into(), "NB1".into(), "Z01".into()],
398        };
399        assert_eq!(key.to_string(), "ZO-T1|BK1|NB1|Z01");
400        assert!(key.is_complete());
401
402        let same_values_other_tuple = CorrelationKey {
403            zuordnung: Zuordnung::ZoT3,
404            elements: vec!["BK1".into(), "NB1".into(), "Z01".into()],
405        };
406        assert_ne!(key.to_string(), same_values_other_tuple.to_string());
407    }
408
409    /// A missing element must hold its position rather than shift the rest.
410    #[test]
411    fn an_absent_element_keeps_its_slot() {
412        let key = CorrelationKey {
413            zuordnung: Zuordnung::ZoT1,
414            elements: vec!["BK1".into(), String::new(), "Z01".into()],
415        };
416        assert_eq!(key.to_string(), "ZO-T1|BK1||Z01");
417        assert!(!key.is_complete());
418        // …and must not collide with a two-element key that happens to match.
419        assert_ne!(
420            key.to_string(),
421            CorrelationKey {
422                zuordnung: Zuordnung::ZoT1,
423                elements: vec!["BK1".into(), "Z01".into()],
424            }
425            .to_string()
426        );
427    }
428}
429
430#[cfg(test)]
431mod process_key_tests {
432    use crate::{DvgwDocument, DvgwPeriod, DvgwPlatform, MessageBuilder, Position, model::nad};
433    use time::macros::datetime;
434
435    fn alocat(pid: u32, day: u8, clearing: &str) -> Vec<u8> {
436        let gas_day = DvgwPeriod {
437            start: datetime!(2026-03-01 05:00 UTC) + time::Duration::days(i64::from(day)),
438            end: datetime!(2026-03-02 05:00 UTC) + time::Duration::days(i64::from(day)),
439        };
440        MessageBuilder::new(DvgwDocument::AllokationSlp)
441            .document_number("ALOCAT1")
442            .version("5.11a")
443            .pruefidentifikator(pid)
444            .message_datetime(datetime!(2026-03-01 04:00 UTC))
445            .validity_period(gas_day)
446            .clearingnummer(clearing)
447            .sender("A")
448            .receiver("B")
449            .position(
450                Position::new()
451                    .item_type("Z01")
452                    .location("Z99", None)
453                    .quantity("Z03", "4000", gas_day)
454                    .status("09G")
455                    .party(nad::BILANZKREIS_INTERN, "BK1")
456                    .party(nad::NETZKONTO_ZO_T3, "NK1"),
457            )
458            .build()
459            .expect("builds")
460    }
461
462    /// Two gas days of the same object are two processes.
463    ///
464    /// `ZO-T3` names (Bilanzkreis, Netzkonto, Zeitreihentyp) and stops there, so
465    /// the tuple alone is the same for every day of the month. An allocation
466    /// process holds one gas day's record and one §6.4 deadline, so keying on the
467    /// tuple would let day two overwrite both of day one's.
468    #[test]
469    fn two_gas_days_of_one_object_are_two_processes() {
470        let platform = DvgwPlatform::default();
471        let day_one = platform.parse(&alocat(70_001, 0, "CLR-A")).unwrap();
472        let day_two = platform.parse(&alocat(70_001, 1, "CLR-A")).unwrap();
473
474        // The published tuple is identical — as specified.
475        assert_eq!(day_one.correlation_key(), day_two.correlation_key());
476        // The process key is not.
477        assert_ne!(day_one.process_key(), day_two.process_key());
478        assert_eq!(
479            day_one.process_key().as_deref(),
480            Some("ZO-T3|BK1|NK1|09G|2026-03-01")
481        );
482    }
483
484    /// A clearing case keeps one key across the days it spans.
485    #[test]
486    fn a_clearing_case_is_not_split_by_gas_day() {
487        let platform = DvgwPlatform::default();
488        let day_one = platform.parse(&alocat(70_008, 0, "CLR-A")).unwrap();
489        let day_two = platform.parse(&alocat(70_008, 1, "CLR-A")).unwrap();
490        assert_eq!(day_one.process_key(), day_two.process_key());
491        assert_eq!(day_one.process_key().as_deref(), Some("ZG-T1|CLR-A"));
492        // A different Clearingfall is a different case.
493        let other = platform.parse(&alocat(70_008, 0, "CLR-B")).unwrap();
494        assert_ne!(day_one.process_key(), other.process_key());
495    }
496
497    /// A tuple that needs a gas day and has none yields no process key, rather
498    /// than one that silently merges with every other dateless message.
499    #[test]
500    fn a_missing_gas_day_yields_no_process_key() {
501        let mut wire = String::from_utf8(alocat(70_001, 0, "CLR-A")).unwrap();
502        wire = wire.replace("DTM+Z01:202603010500202603020500:719'", "");
503        let msg = DvgwPlatform::default().parse(wire.as_bytes()).unwrap();
504        assert!(msg.correlation_key().is_some(), "the tuple still resolves");
505        assert_eq!(msg.process_key(), None, "but the process does not");
506    }
507}