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}