Skip to main content

dvgw_edi/
builder.rs

1//! Writing DVGW messages.
2//!
3//! A BKV that only parses cannot nominate, and a Netzbetreiber that only
4//! parses cannot report its Mehr-/Mindermengen. [`MessageBuilder`] renders the
5//! header and `LIN` loops the Nachrichtenbeschreibungen prescribe, so the same
6//! crate that reads a NOMRES can produce the NOMINT it answers and the SSQNOT
7//! the MGV expects.
8//!
9//! Every coded value is stamped with the agency the Segmentlayout names —
10//! `332` (DVGW) by default, `9` (GS1) where a party is a GLN — and a profile
11//! is written the way the DVGW column caps it: one `DTM+2` and one `QTY` per
12//! `LOC` group, the `LOC` repeated per period.
13//!
14//! ```rust
15//! use dvgw_edi::{DvgwDocument, DvgwPeriod, MessageBuilder, Position};
16//! use time::macros::datetime;
17//!
18//! let gas_day = DvgwPeriod {
19//!     start: datetime!(2026-03-01 05:00 UTC),
20//!     end:   datetime!(2026-03-02 05:00 UTC),
21//! };
22//!
23//! let wire = MessageBuilder::new(DvgwDocument::NominierungTransportkunde)
24//!     .message_ref("1")
25//!     .document_number("NOMINT00052")
26//!     .version("DVGW17")
27//!     .pruefidentifikator(70030)
28//!     .message_datetime(datetime!(2026-02-28 20:56 UTC))
29//!     .validity_period(gas_day)
30//!     .sender("9870009700005")
31//!     .receiver("9870009700006")
32//!     .position(
33//!         Position::new()
34//!             .location("Z19", Some("ABCD1234"))
35//!             .quantity("Z03", "6782", gas_day)
36//!             .party("ZEU", "BK-CODE-1")
37//!             .party("ZES", "BK-CODE-2"),
38//!     )
39//!     .build()?;
40//!
41//! assert!(String::from_utf8_lossy(&wire).contains("BGM+01G::332+NOMINT00052'"));
42//! # Ok::<(), dvgw_edi::Error>(())
43//! ```
44
45use time::{OffsetDateTime, UtcOffset};
46
47use crate::{
48    datetime::{DvgwPeriod, format_instant, format_period},
49    document::{DVGW_AGENCY_CODE, DvgwDocument},
50    error::Error,
51    model::{nad, rff},
52};
53
54/// One `LIN` position under construction.
55#[derive(Debug, Clone, Default)]
56pub struct Position {
57    number: Option<String>,
58    item_type: Option<String>,
59    description: Option<String>,
60    locations: Vec<LocationDraft>,
61    parties: Vec<PartyDraft>,
62}
63
64#[derive(Debug, Clone)]
65struct PartyDraft {
66    role: String,
67    code: String,
68    agency: String,
69}
70
71#[derive(Debug, Clone)]
72struct LocationDraft {
73    qualifier: String,
74    code: Option<String>,
75    quantities: Vec<QuantityDraft>,
76}
77
78#[derive(Debug, Clone)]
79struct QuantityDraft {
80    qualifier: String,
81    value: String,
82    unit: Option<String>,
83    period: DvgwPeriod,
84    status: Vec<String>,
85}
86
87impl Position {
88    /// An empty position.
89    #[must_use]
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Set `LIN` DE 1082 explicitly. Positions are numbered from 1 otherwise.
95    #[must_use]
96    pub fn number(mut self, number: impl Into<String>) -> Self {
97        self.number = Some(number.into());
98        self
99    }
100
101    /// Set `LIN` C212 DE 7143 — the Zeitreihentyp in ALOCAT.
102    #[must_use]
103    pub fn item_type(mut self, code: impl Into<String>) -> Self {
104        self.item_type = Some(code.into());
105        self
106    }
107
108    /// Set the `IMD` DE 7009 code — NOMRES labels which side a position reports.
109    #[must_use]
110    pub fn description(mut self, code: impl Into<String>) -> Self {
111        self.description = Some(code.into());
112        self
113    }
114
115    /// Open a `LOC` group. Pass `None` as the code for `LOC+Z99`, which ALOCAT
116    /// sends when the message needs no specific place.
117    #[must_use]
118    pub fn location(mut self, qualifier: impl Into<String>, code: Option<&str>) -> Self {
119        self.locations.push(LocationDraft {
120            qualifier: qualifier.into(),
121            code: code.map(str::to_owned),
122            quantities: Vec::new(),
123        });
124        self
125    }
126
127    /// Add a quantity to the open `LOC` group, with the period it applies to,
128    /// in the family's default unit (`KW1` kWh/h; `KWH` for SSQNOT).
129    ///
130    /// Repeat to transmit a profile: the DVGW column admits one `DTM+2` and
131    /// one `QTY` per `LOC` group, so every quantity after the first is
132    /// written under a repeated `LOC`.
133    ///
134    /// # Panics
135    ///
136    /// Panics when no [`location`](Self::location) has been opened yet — a
137    /// quantity outside a `LOC` group has nowhere to go on the wire.
138    #[must_use]
139    pub fn quantity(
140        self,
141        qualifier: impl Into<String>,
142        value: impl Into<String>,
143        period: DvgwPeriod,
144    ) -> Self {
145        self.push_quantity(qualifier.into(), value.into(), None, period)
146    }
147
148    /// [`quantity`](Self::quantity) in an explicit C186 DE 6411 unit —
149    /// `KW2` (kWh/d) on an ALOCAT, `KWH` on a nomination.
150    ///
151    /// # Panics
152    ///
153    /// As [`quantity`](Self::quantity).
154    #[must_use]
155    pub fn quantity_in(
156        self,
157        qualifier: impl Into<String>,
158        value: impl Into<String>,
159        unit: impl Into<String>,
160        period: DvgwPeriod,
161    ) -> Self {
162        self.push_quantity(qualifier.into(), value.into(), Some(unit.into()), period)
163    }
164
165    fn push_quantity(
166        mut self,
167        qualifier: String,
168        value: String,
169        unit: Option<String>,
170        period: DvgwPeriod,
171    ) -> Self {
172        let location = self
173            .locations
174            .last_mut()
175            .expect("call Position::location before Position::quantity");
176        location.quantities.push(QuantityDraft {
177            qualifier,
178            value,
179            unit,
180            period,
181            status: Vec::new(),
182        });
183        self
184    }
185
186    /// Attach an `STS` DE 9015 code to the last quantity — the Zeitreihentyp
187    /// of an ALOCAT (`09G`, `14G`, …), the Verfahren of a SSQNOT (`A1G`/`A2G`).
188    ///
189    /// # Panics
190    ///
191    /// Panics when no quantity has been added yet.
192    #[must_use]
193    pub fn status(mut self, code: impl Into<String>) -> Self {
194        self.locations
195            .last_mut()
196            .and_then(|l| l.quantities.last_mut())
197            .expect("call Position::quantity before Position::status")
198            .status
199            .push(code.into());
200        self
201    }
202
203    /// Add a position-level `NAD` — Bilanzkreis, Netzkonto, VHP, Netzbetreiber —
204    /// coded under the DVGW agency (`332`).
205    #[must_use]
206    pub fn party(self, role: impl Into<String>, code: impl Into<String>) -> Self {
207        self.party_coded(role, code, DVGW_AGENCY_CODE)
208    }
209
210    /// [`party`](Self::party) under an explicit DE 3055 agency — `9` for a GLN,
211    /// which ALOCAT admits on the `ZSO`/`VHP` row.
212    #[must_use]
213    pub fn party_coded(
214        mut self,
215        role: impl Into<String>,
216        code: impl Into<String>,
217        agency: impl Into<String>,
218    ) -> Self {
219        self.parties.push(PartyDraft {
220            role: role.into(),
221            code: code.into(),
222            agency: agency.into(),
223        });
224        self
225    }
226}
227
228/// Escape the EDIFACT service characters in a value.
229///
230/// A value containing `\'` would otherwise close the segment early and have
231/// everything after it read as further segments — outbound messages are
232/// assembled from counterparty-supplied identifiers.
233fn esc(value: &str) -> String {
234    let mut out = String::with_capacity(value.len());
235    for c in value.chars() {
236        if matches!(c, '+' | ':' | '\'' | '?') {
237            out.push('?');
238        }
239        out.push(c);
240    }
241    out
242}
243
244/// Builds a complete DVGW message.
245///
246/// The document code decides the family, the carrier and the `UNH` header, so
247/// there is one builder rather than one per message type.
248#[derive(Debug, Clone)]
249pub struct MessageBuilder {
250    document: DvgwDocument,
251    message_ref: String,
252    document_number: String,
253    version: Option<String>,
254    timezone: UtcOffset,
255    pruefidentifikator: Option<u32>,
256    message_datetime: Option<OffsetDateTime>,
257    validity_period: Option<DvgwPeriod>,
258    clearingnummer: Option<String>,
259    original_nomination: Option<(String, OffsetDateTime)>,
260    references: Vec<(String, String)>,
261    parties: Vec<PartyDraft>,
262    positions: Vec<Position>,
263}
264
265impl MessageBuilder {
266    /// Start a message of the given document type.
267    #[must_use]
268    pub fn new(document: DvgwDocument) -> Self {
269        Self {
270            document,
271            message_ref: "1".to_owned(),
272            document_number: String::new(),
273            version: None,
274            timezone: UtcOffset::UTC,
275            pruefidentifikator: None,
276            message_datetime: None,
277            validity_period: None,
278            clearingnummer: None,
279            original_nomination: None,
280            references: Vec::new(),
281            parties: Vec::new(),
282            positions: Vec::new(),
283        }
284    }
285
286    /// `UNH` DE 0062 message reference (mirrored in `UNT`).
287    #[must_use]
288    pub fn message_ref(mut self, value: impl Into<String>) -> Self {
289        self.message_ref = value.into();
290        self
291    }
292
293    /// `BGM` C106 DE 1004 Dokumentennummer.
294    #[must_use]
295    pub fn document_number(mut self, value: impl Into<String>) -> Self {
296        self.document_number = value.into();
297        self
298    }
299
300    /// `UNH` S009 DE 0057 — the package code (`DVGW17`) or version (`5.11a`).
301    /// Defaults to what the family's Nachrichtenbeschreibung prescribes
302    /// ([`DvgwMessageType::anwendungscode`](crate::DvgwMessageType::anwendungscode)).
303    #[must_use]
304    pub fn version(mut self, value: impl Into<String>) -> Self {
305        self.version = Some(value.into());
306        self
307    }
308
309    /// `SG1 RFF+Z13` Prüfidentifikator.
310    #[must_use]
311    pub fn pruefidentifikator(mut self, pid: u32) -> Self {
312        self.pruefidentifikator = Some(pid);
313        self
314    }
315
316    /// `DTM+137` Datum und Zeit der Nachricht.
317    #[must_use]
318    pub fn message_datetime(mut self, value: OffsetDateTime) -> Self {
319        self.message_datetime = Some(value);
320        self
321    }
322
323    /// `DTM+Z01` Gültigkeitszeitraum — the gas day for ALOCAT, NOMINT and
324    /// NOMRES, the Abrechnungszeitraum for SSQNOT.
325    #[must_use]
326    pub fn validity_period(mut self, period: DvgwPeriod) -> Self {
327        self.validity_period = Some(period);
328        self
329    }
330
331    /// `NAD+MS` Absender, coded under the DVGW agency (`332`).
332    #[must_use]
333    pub fn sender(self, code: impl Into<String>) -> Self {
334        self.party(nad::ABSENDER, code)
335    }
336
337    /// `NAD+MS` Absender under an explicit DE 3055 agency (`9` for a GLN).
338    #[must_use]
339    pub fn sender_coded(self, code: impl Into<String>, agency: impl Into<String>) -> Self {
340        self.party_coded(nad::ABSENDER, code, agency)
341    }
342
343    /// `NAD+MR` Empfänger, coded under the DVGW agency (`332`).
344    #[must_use]
345    pub fn receiver(self, code: impl Into<String>) -> Self {
346        self.party(nad::EMPFAENGER, code)
347    }
348
349    /// `NAD+MR` Empfänger under an explicit DE 3055 agency (`9` for a GLN).
350    #[must_use]
351    pub fn receiver_coded(self, code: impl Into<String>, agency: impl Into<String>) -> Self {
352        self.party_coded(nad::EMPFAENGER, code, agency)
353    }
354
355    /// Any further header `NAD`, e.g. `ZSY` (zusätzlicher BKV), under `332`.
356    #[must_use]
357    pub fn party(self, role: impl Into<String>, code: impl Into<String>) -> Self {
358        self.party_coded(role, code, DVGW_AGENCY_CODE)
359    }
360
361    /// [`party`](Self::party) under an explicit DE 3055 agency.
362    #[must_use]
363    pub fn party_coded(
364        mut self,
365        role: impl Into<String>,
366        code: impl Into<String>,
367        agency: impl Into<String>,
368    ) -> Self {
369        self.parties.push(PartyDraft {
370            role: role.into(),
371            code: code.into(),
372            agency: agency.into(),
373        });
374        self
375    }
376
377    /// `RFF+ANX` Clearingnummer (ALOCAT), written first in `SG1` as the
378    /// Nachrichtenstruktur orders it.
379    #[must_use]
380    pub fn clearingnummer(mut self, value: impl Into<String>) -> Self {
381        self.clearingnummer = Some(value.into());
382        self
383    }
384
385    /// `RFF+AGO` — the nomination this one corrects (NOMINT) — with the
386    /// `DTM+9` Bearbeitungsdatum NOMINT 4.6 marks Erforderlich beside it.
387    #[must_use]
388    pub fn original_nomination(
389        mut self,
390        value: impl Into<String>,
391        processed_at: OffsetDateTime,
392    ) -> Self {
393        self.original_nomination = Some((value.into(), processed_at));
394        self
395    }
396
397    /// Any further header `RFF`, written after the ones the structure orders.
398    #[must_use]
399    pub fn reference(mut self, qualifier: impl Into<String>, value: impl Into<String>) -> Self {
400        self.references.push((qualifier.into(), value.into()));
401        self
402    }
403
404    /// Append a position.
405    #[must_use]
406    pub fn position(mut self, position: Position) -> Self {
407        self.positions.push(position);
408        self
409    }
410
411    /// Render the message as `UNH`…`UNT` EDIFACT bytes.
412    ///
413    /// The interchange envelope is deliberately not written: the AS4 layer owns
414    /// `UNB`/`UNZ` and its control reference, and a second writer would have to
415    /// guess at both.
416    ///
417    /// # Errors
418    ///
419    /// Returns [`Error::Serialize`] when a mandatory field was never set — the
420    /// Dokumentennummer, the Prüfidentifikator, the two timestamps, both parties,
421    /// or at least one position.
422    pub fn build(&self) -> Result<Vec<u8>, Error> {
423        let missing = |what: &str| Error::Serialize(format!("{what} is required but was not set"));
424
425        if self.document_number.is_empty() {
426            return Err(missing("BGM C106 DE 1004 Dokumentennummer"));
427        }
428        let pid = self
429            .pruefidentifikator
430            .ok_or_else(|| missing("SG1 RFF+Z13 Prüfidentifikator"))?;
431        let message_datetime = self
432            .message_datetime
433            .ok_or_else(|| missing("DTM+137 Datum und Zeit der Nachricht"))?;
434        let validity = self
435            .validity_period
436            .ok_or_else(|| missing("DTM+Z01 Gültigkeitszeitraum"))?;
437        for role in [nad::ABSENDER, nad::EMPFAENGER] {
438            if !self.parties.iter().any(|p| p.role == role) {
439                return Err(missing(&format!("NAD+{role}")));
440            }
441        }
442        if self.positions.is_empty() {
443            return Err(missing("at least one LIN position"));
444        }
445
446        let agency = DVGW_AGENCY_CODE;
447        let mut segments: Vec<String> = Vec::new();
448
449        let family = self.document.message_type();
450        let version = self
451            .version
452            .as_deref()
453            .unwrap_or_else(|| family.anwendungscode());
454        segments.push(format!(
455            "UNH+{}+{}:D:07A:UN:{}",
456            esc(&self.message_ref),
457            self.document.carrier().as_str(),
458            esc(version)
459        ));
460        segments.push(format!(
461            "BGM+{}::{agency}+{}",
462            self.document.code(),
463            esc(&self.document_number)
464        ));
465        // The zone must precede the timestamps it governs.
466        segments.push(format!("DTM+Z05:{}:805", self.timezone.whole_hours()));
467        segments.push(format!(
468            "DTM+137:{}:203",
469            format_instant(message_datetime, self.timezone)
470        ));
471        segments.push(format!(
472            "DTM+Z01:{}:719",
473            format_period(validity, self.timezone)
474        ));
475        // `SG1` in the order the Nachrichtenstrukturen list it: ALOCAT puts
476        // the Clearingnummer before the Prüfidentifikator, NOMINT the
477        // Original-Nominierung (with its `DTM+9`) after it.
478        if let Some(clearing) = &self.clearingnummer {
479            segments.push(format!("RFF+{}:{}", rff::CLEARINGNUMMER, esc(clearing)));
480        }
481        segments.push(format!("RFF+{}:{pid}", rff::PRUEFIDENTIFIKATOR));
482        if let Some((original, processed_at)) = &self.original_nomination {
483            segments.push(format!(
484                "RFF+{}:{}",
485                rff::ORIGINAL_NOMINIERUNG,
486                esc(original)
487            ));
488            segments.push(format!(
489                "DTM+9:{}:203",
490                format_instant(*processed_at, self.timezone)
491            ));
492        }
493        for (qualifier, value) in &self.references {
494            segments.push(format!("RFF+{}:{}", esc(qualifier), esc(value)));
495        }
496        for party in &self.parties {
497            segments.push(format!(
498                "NAD+{}+{}::{}",
499                esc(&party.role),
500                esc(&party.code),
501                esc(&party.agency)
502            ));
503        }
504
505        let default_unit = family.admitted_units()[0];
506        for (index, position) in self.positions.iter().enumerate() {
507            self.render_position(position, index, agency, default_unit, &mut segments);
508        }
509
510        segments.push("UNS+S".to_owned());
511        // UNT DE 0074 counts UNH…UNT inclusive: everything rendered so far plus
512        // the UNT itself.
513        segments.push(format!(
514            "UNT+{}+{}",
515            segments.len() + 1,
516            esc(&self.message_ref)
517        ));
518
519        let mut out = String::new();
520        for segment in segments {
521            out.push_str(&segment);
522            out.push('\'');
523        }
524        Ok(out.into_bytes())
525    }
526
527    /// Render one `LIN` loop.
528    fn render_position(
529        &self,
530        position: &Position,
531        index: usize,
532        agency: &str,
533        default_unit: &str,
534        segments: &mut Vec<String>,
535    ) {
536        {
537            let number = position
538                .number
539                .clone()
540                .unwrap_or_else(|| (index + 1).to_string());
541            let number = esc(&number);
542            match &position.item_type {
543                Some(item_type) => {
544                    segments.push(format!("LIN+{number}++:{}::{agency}", esc(item_type)));
545                }
546                None => segments.push(format!("LIN+{number}")),
547            }
548            if let Some(code) = &position.description {
549                segments.push(format!("IMD++05G+{}::{agency}", esc(code)));
550            }
551            for location in &position.locations {
552                let loc = match &location.code {
553                    Some(code) => {
554                        format!("LOC+{}+{}::{agency}", esc(&location.qualifier), esc(code))
555                    }
556                    None => format!("LOC+{}", esc(&location.qualifier)),
557                };
558                // One `DTM+2` and one `QTY` per `LOC` group (the DVGW MaxWdh),
559                // so a profile repeats the `LOC`.
560                for quantity in &location.quantities {
561                    segments.push(loc.clone());
562                    segments.push(format!(
563                        "DTM+2:{}:719",
564                        format_period(quantity.period, self.timezone)
565                    ));
566                    segments.push(format!(
567                        "QTY+{}:{}:{}",
568                        esc(&quantity.qualifier),
569                        esc(&quantity.value),
570                        esc(quantity.unit.as_deref().unwrap_or(default_unit))
571                    ));
572                    for status in &quantity.status {
573                        segments.push(format!("STS+{}::{agency}", esc(status)));
574                    }
575                }
576                if location.quantities.is_empty() {
577                    segments.push(loc);
578                }
579            }
580            for party in &position.parties {
581                segments.push(format!(
582                    "NAD+{}+{}::{}",
583                    esc(&party.role),
584                    esc(&party.code),
585                    esc(&party.agency)
586                ));
587            }
588        }
589    }
590}