Skip to main content

dvgw_edi/
message.rs

1//! [`DvgwMessage`] — a parsed DVGW message and the walk that builds it.
2
3use edifact_rs::OwnedSegment;
4use time::{OffsetDateTime, UtcOffset};
5
6use crate::{
7    datetime::{self, DtmFormat, DtmValue, DvgwPeriod},
8    document::{Carrier, DvgwDocument, DvgwMessageType},
9    error::{Error, sanitize_code},
10    model::{ItemDescription, LineItem, LocationGroup, Party, Quantity, Reference, nad, rff},
11    pruefidentifikator::Pruefidentifikator,
12    version::DvgwVersion,
13};
14
15/// A parsed DVGW message.
16///
17/// One type serves ALOCAT, NOMINT, NOMRES and SSQNOT: the four share a
18/// structure and differ only in which qualifiers are legal, which is a
19/// validation concern.
20/// Match on [`message_type`](Self::message_type) or
21/// [`document`](Self::document) when the family matters.
22#[derive(Debug, Clone)]
23#[non_exhaustive]
24pub struct DvgwMessage {
25    /// The logical family, derived from the document code.
26    pub message_type: DvgwMessageType,
27    /// `BGM` C002 DE 1001 — what this message *is*.
28    pub document: DvgwDocument,
29    /// `UNH` DE 0065 — the UN/EDIFACT carrier the document rode in on.
30    pub carrier: Carrier,
31    /// `UNH` S009 DE 0057 — the DVGW package code or message version.
32    pub version: Option<DvgwVersion>,
33    /// `UNH` DE 0062 message reference.
34    pub message_ref: String,
35    /// `BGM` C106 DE 1004 Dokumentennummer.
36    pub document_number: Option<String>,
37    /// `SG1 RFF+Z13` Prüfidentifikator.
38    pub pruefidentifikator: Option<Pruefidentifikator>,
39    /// The zone `DTM+Z05` declares, as a whole-hour offset. Defaults to UTC,
40    /// which is what `DTM+Z05:0:805` says and what every shipped package uses.
41    pub timezone: UtcOffset,
42    /// `DTM+137` — Datum und Zeit der Nachricht.
43    pub message_datetime: Option<OffsetDateTime>,
44    /// `DTM+Z01` — Gültigkeitszeitraum der Nachricht.
45    ///
46    /// For ALOCAT, NOMINT and NOMRES this is the gas day the message reports
47    /// on; for SSQNOT the Abrechnungszeitraum of the Mehr-/Mindermenge.
48    pub validity_period: Option<DvgwPeriod>,
49    /// `SG1 DTM+9` — Bearbeitungs-/Verarbeitungsdatum of the original
50    /// nomination a re-nomination corrects (NOMINT, beside `RFF+AGO`).
51    pub original_nomination_datetime: Option<OffsetDateTime>,
52    /// Header `RFF` segments in wire order.
53    pub references: Vec<Reference>,
54    /// Header parties in wire order (`NAD+MS`, `NAD+MR`, `NAD+ZSY`).
55    pub parties: Vec<Party>,
56    /// The `LIN` positions.
57    pub items: Vec<LineItem>,
58    /// Header `DTM` segments that were present but could not be decoded against
59    /// their own format code. Carried so validation can report them precisely.
60    pub(crate) undecodable_dtm: Vec<String>,
61    /// The raw segments, authoritative for serialization.
62    segments: Vec<OwnedSegment>,
63}
64
65impl DvgwMessage {
66    /// The sender from `NAD+MS`.
67    #[must_use]
68    pub fn sender(&self) -> Option<&Party> {
69        self.party(nad::ABSENDER)
70    }
71
72    /// The receiver from `NAD+MR`.
73    #[must_use]
74    pub fn receiver(&self) -> Option<&Party> {
75        self.party(nad::EMPFAENGER)
76    }
77
78    /// The first header party with the given `NAD` role.
79    #[must_use]
80    pub fn party(&self, role: &str) -> Option<&Party> {
81        self.parties.iter().find(|p| p.role == role)
82    }
83
84    /// The first header reference with the given `RFF` qualifier.
85    #[must_use]
86    pub fn reference(&self, qualifier: &str) -> Option<&str> {
87        self.references
88            .iter()
89            .find(|r| r.qualifier == qualifier)
90            .map(|r| r.value.as_str())
91    }
92
93    /// `RFF+ANX` — the ALOCAT Clearingnummer.
94    #[must_use]
95    pub fn clearingnummer(&self) -> Option<&str> {
96        self.reference(rff::CLEARINGNUMMER)
97    }
98
99    /// `RFF+AGO` — the NOMINT reference to the nomination this one corrects.
100    ///
101    /// This is the correlation key for a re-nomination chain. `RFF+Z13` is the
102    /// Prüfidentifikator and correlates nothing.
103    #[must_use]
104    pub fn original_nomination_ref(&self) -> Option<&str> {
105        self.reference(rff::ORIGINAL_NOMINIERUNG)
106    }
107
108    /// Every quantity in the message, flattened across positions and locations.
109    pub fn quantities(&self) -> impl Iterator<Item = &Quantity> {
110        self.items.iter().flat_map(LineItem::quantities)
111    }
112
113    /// Energy totals in kWh, one per `QTY` qualifier.
114    ///
115    /// Each quantity is integrated over its own period
116    /// ([`Quantity::energy_kwh`]) and summed within its qualifier — the
117    /// qualifier is the direction (`Z02` in, `Z03` out), so a figure across them
118    /// would be a net position.
119    ///
120    /// A quantity that cannot be converted is **omitted**;
121    /// [`energy_is_complete`](Self::energy_is_complete) reports whether any were.
122    #[must_use]
123    pub fn energy_by_qualifier(&self) -> crate::model::EnergyByQualifier {
124        self.energy_by_qualifier_where(|_| true)
125    }
126
127    /// [`energy_by_qualifier`](Self::energy_by_qualifier) over the positions
128    /// `keep` selects.
129    ///
130    /// For NOMRES, which reports **both** sides of a match: `IMD` `17G` labels
131    /// the quantities the recipient nominated, `18G` the counterparty's, `16G`
132    /// the matched result.
133    #[must_use]
134    pub fn energy_by_qualifier_where(
135        &self,
136        keep: impl Fn(&LineItem) -> bool,
137    ) -> crate::model::EnergyByQualifier {
138        let mut totals = crate::model::EnergyByQualifier::new();
139        for quantity in self
140            .items
141            .iter()
142            .filter(|item| keep(item))
143            .flat_map(LineItem::quantities)
144        {
145            if let Some(kwh) = quantity.energy_kwh() {
146                *totals.entry(quantity.qualifier.clone()).or_default() += kwh;
147            }
148        }
149        totals
150    }
151
152    /// The single energy total this message states, in kWh, or `None`.
153    ///
154    /// `None` when the selected positions carry more than one `QTY` qualifier —
155    /// `Z02` in and `Z03` out make a net position, not a total — and `None` when
156    /// nothing could be integrated or some quantity was dropped.
157    /// [`energy_by_qualifier_where`](Self::energy_by_qualifier_where) gives the
158    /// per-direction figures.
159    #[must_use]
160    pub fn single_energy_kwh(
161        &self,
162        keep: impl Fn(&LineItem) -> bool + Copy,
163    ) -> Option<rust_decimal::Decimal> {
164        let selected: Vec<&LineItem> = self.items.iter().filter(|i| keep(i)).collect();
165        if selected.is_empty() {
166            return None;
167        }
168        // Every selected quantity must have contributed, or the total is a floor.
169        let complete = selected
170            .iter()
171            .flat_map(|i| i.quantities())
172            .all(|q| q.energy_kwh().is_some());
173        if !complete {
174            return None;
175        }
176        let totals = self.energy_by_qualifier_where(keep);
177        match totals.len() {
178            1 => totals.into_values().next(),
179            _ => None,
180        }
181    }
182
183    /// `true` when every quantity in the message contributed to
184    /// [`energy_by_qualifier`](Self::energy_by_qualifier).
185    ///
186    /// `false` means at least one was dropped, so the totals are a floor rather
187    /// than a figure — check this before booking one.
188    #[must_use]
189    pub fn energy_is_complete(&self) -> bool {
190        let mut any = false;
191        for quantity in self.quantities() {
192            any = true;
193            if quantity.energy_kwh().is_none() {
194                return false;
195            }
196        }
197        any
198    }
199
200    /// The raw segments (`UNH` … `UNT`, plus any envelope that was parsed with them).
201    #[must_use]
202    pub fn segments(&self) -> &[OwnedSegment] {
203        &self.segments
204    }
205
206    /// Render the message back to EDIFACT bytes.
207    ///
208    /// Serialization replays the raw segments, so edits to the typed fields are
209    /// **not** reflected. Build an outbound message with
210    /// [`MessageBuilder`](crate::MessageBuilder) instead of mutating a parsed one.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`Error::Serialize`] when a segment value cannot be encoded.
215    pub fn serialize(&self) -> Result<Vec<u8>, Error> {
216        edifact_rs::segments_to_bytes(&self.segments).map_err(|e| Error::Serialize(e.to_string()))
217    }
218
219    // ── Construction ─────────────────────────────────────────────────────────
220
221    /// Identify and parse one message from its segments.
222    ///
223    /// # Errors
224    ///
225    /// - [`Error::MissingSegment`] — no `UNH` or no `BGM`.
226    /// - [`Error::UnknownDocumentCode`] — `BGM` DE 1001 is not a DVGW code.
227    /// - [`Error::CarrierMismatch`] — `UNH` DE 0065 contradicts the document code.
228    pub(crate) fn from_segments(segments: Vec<OwnedSegment>) -> Result<Self, Error> {
229        let unh = find(&segments, "UNH").ok_or(Error::MissingSegment("UNH"))?;
230        let message_ref = unh.element_str(0).unwrap_or_default().to_owned();
231        let carrier_code = unh.component_str(1, 0).unwrap_or_default().to_owned();
232        let version = unh.component_str(1, 4).and_then(DvgwVersion::parse);
233
234        let bgm = find(&segments, "BGM").ok_or(Error::MissingSegment("BGM"))?;
235        let document_code = bgm.component_str(0, 0).unwrap_or_default();
236        let document =
237            DvgwDocument::from_code(document_code).ok_or_else(|| Error::UnknownDocumentCode {
238                raw_code: sanitize_code(document_code),
239            })?;
240        let document_number = bgm.component_str(1, 0).map(str::to_owned);
241
242        // The carrier is a cross-check on the identity, not the identity itself.
243        let expected = document.carrier();
244        let carrier = match Carrier::from_unh_code(&carrier_code) {
245            Some(c) if c == expected => c,
246            _ => {
247                return Err(Error::CarrierMismatch {
248                    document: document.code(),
249                    expected: expected.as_str(),
250                    raw_code: sanitize_code(&carrier_code),
251                });
252            }
253        };
254
255        // `DTM+Z05` declares the zone every other timestamp is read in, so it is
256        // resolved before any of them.
257        let mut undecodable_dtm = Vec::new();
258        let timezone = header_dtm(&segments, "Z05", UtcOffset::UTC, &mut undecodable_dtm)
259            .and_then(DtmValue::as_hours)
260            .and_then(|h| UtcOffset::from_hms(h, 0, 0).ok())
261            .unwrap_or(UtcOffset::UTC);
262
263        let message_datetime = header_dtm(&segments, "137", timezone, &mut undecodable_dtm)
264            .and_then(DtmValue::as_instant);
265        let validity_period = header_dtm(&segments, "Z01", timezone, &mut undecodable_dtm)
266            .and_then(DtmValue::as_period);
267        let original_nomination_datetime =
268            header_dtm(&segments, "9", timezone, &mut undecodable_dtm)
269                .and_then(DtmValue::as_instant);
270
271        let header_end = segments
272            .iter()
273            .position(|s| s.tag == "LIN")
274            .unwrap_or(segments.len());
275        let header = &segments[..header_end];
276
277        let references: Vec<Reference> = header
278            .iter()
279            .filter(|s| s.tag == "RFF")
280            .filter_map(read_reference)
281            .collect();
282        let parties: Vec<Party> = header
283            .iter()
284            .filter(|s| s.tag == "NAD")
285            .filter_map(read_party)
286            .collect();
287        let pruefidentifikator = references
288            .iter()
289            .find(|r| r.qualifier == rff::PRUEFIDENTIFIKATOR)
290            .and_then(|r| r.value.parse::<Pruefidentifikator>().ok());
291
292        let items = parse_items(&segments[header_end..], timezone, &mut undecodable_dtm);
293
294        Ok(Self {
295            message_type: document.message_type(),
296            document,
297            carrier,
298            version,
299            message_ref,
300            document_number,
301            pruefidentifikator,
302            timezone,
303            message_datetime,
304            validity_period,
305            original_nomination_datetime,
306            references,
307            parties,
308            items,
309            undecodable_dtm,
310            segments,
311        })
312    }
313}
314
315// ── Segment readers ───────────────────────────────────────────────────────────
316
317fn find<'a>(segments: &'a [OwnedSegment], tag: &str) -> Option<&'a OwnedSegment> {
318    segments.iter().find(|s| s.tag == tag)
319}
320
321fn read_reference(seg: &OwnedSegment) -> Option<Reference> {
322    Some(Reference {
323        qualifier: seg.component_str(0, 0)?.to_owned(),
324        value: seg.component_str(0, 1).unwrap_or_default().to_owned(),
325    })
326}
327
328fn read_party(seg: &OwnedSegment) -> Option<Party> {
329    Some(Party {
330        role: seg.element_str(0)?.to_owned(),
331        id: seg.component_str(1, 0).unwrap_or_default().to_owned(),
332        agency: seg
333            .component_str(1, 2)
334            .filter(|a| !a.is_empty())
335            .map(str::to_owned),
336    })
337}
338
339fn read_item_description(seg: &OwnedSegment) -> ItemDescription {
340    ItemDescription {
341        characteristic: seg
342            .element_str(1)
343            .filter(|s| !s.is_empty())
344            .map(str::to_owned),
345        code: seg
346            .component_str(2, 0)
347            .filter(|s| !s.is_empty())
348            .map(str::to_owned),
349    }
350}
351
352/// Decode a `DTM` against its own format code.
353///
354/// A `DTM` whose value does not match its declared format is recorded in
355/// `undecodable` rather than dropped, so validation can name it.
356fn read_dtm(
357    seg: &OwnedSegment,
358    offset: UtcOffset,
359    undecodable: &mut Vec<String>,
360) -> Option<DtmValue> {
361    let qualifier = seg.component_str(0, 0)?;
362    let value = seg.component_str(0, 1).unwrap_or_default();
363    let format = seg.component_str(0, 2).and_then(DtmFormat::from_code);
364    let Some(format) = format else {
365        undecodable.push(qualifier.to_owned());
366        return None;
367    };
368    let decoded = datetime::decode(value, format, offset);
369    if decoded.is_none() {
370        undecodable.push(qualifier.to_owned());
371    }
372    decoded
373}
374
375fn header_dtm(
376    segments: &[OwnedSegment],
377    qualifier: &str,
378    offset: UtcOffset,
379    undecodable: &mut Vec<String>,
380) -> Option<DtmValue> {
381    let header_end = segments
382        .iter()
383        .position(|s| s.tag == "LIN")
384        .unwrap_or(segments.len());
385    let seg = segments[..header_end]
386        .iter()
387        .find(|s| s.tag == "DTM" && s.component_str(0, 0) == Some(qualifier))?;
388    read_dtm(seg, offset, undecodable)
389}
390
391/// Walk the `LIN` loops, keeping the `LOC` → `DTM` → `QTY` → `STS` nesting.
392///
393/// The walk is a small state machine over the segment order rather than a scan
394/// for tags, because the meaning of a `DTM` or a `NAD` depends entirely on which
395/// group is open when it appears.
396fn parse_items(
397    segments: &[OwnedSegment],
398    offset: UtcOffset,
399    undecodable: &mut Vec<String>,
400) -> Vec<LineItem> {
401    let mut items: Vec<LineItem> = Vec::new();
402    // The `DTM+2` currently in effect inside the open `LOC` group.
403    let mut current_period: Option<DvgwPeriod> = None;
404
405    for seg in segments {
406        match &*seg.tag {
407            "LIN" => {
408                items.push(LineItem {
409                    number: seg
410                        .element_str(0)
411                        .filter(|s| !s.is_empty())
412                        .map(str::to_owned),
413                    // C212 sits in element 2; DE 7143 is its second component
414                    // (`LIN+1++:Z01::332` — the Zeitreihentyp).
415                    item_type: seg
416                        .component_str(2, 1)
417                        .filter(|s| !s.is_empty())
418                        .map(str::to_owned),
419                    descriptions: Vec::new(),
420                    locations: Vec::new(),
421                    parties: Vec::new(),
422                });
423                current_period = None;
424            }
425            "IMD" => {
426                if let Some(item) = items.last_mut() {
427                    item.descriptions.push(read_item_description(seg));
428                }
429            }
430            "LOC" => {
431                if let Some(item) = items.last_mut() {
432                    item.locations.push(LocationGroup {
433                        qualifier: seg.element_str(0).unwrap_or_default().to_owned(),
434                        code: seg
435                            .component_str(1, 0)
436                            .filter(|s| !s.is_empty())
437                            .map(str::to_owned),
438                        agency: seg
439                            .component_str(1, 2)
440                            .filter(|s| !s.is_empty())
441                            .map(str::to_owned),
442                        quantities: Vec::new(),
443                    });
444                }
445                current_period = None;
446            }
447            // Inside a position, `DTM+2` sets the period for the quantities that
448            // follow it — several may alternate to transmit a profile.
449            "DTM" => {
450                if let Some(period) =
451                    read_dtm(seg, offset, undecodable).and_then(DtmValue::as_period)
452                {
453                    current_period = Some(period);
454                }
455            }
456            "QTY" => {
457                let raw_value = seg.component_str(0, 1).unwrap_or_default().to_owned();
458                let quantity = Quantity {
459                    qualifier: seg.component_str(0, 0).unwrap_or_default().to_owned(),
460                    value: raw_value.parse().ok(),
461                    raw_value,
462                    unit: seg
463                        .component_str(0, 2)
464                        .filter(|s| !s.is_empty())
465                        .map(str::to_owned),
466                    period: current_period,
467                    status: Vec::new(),
468                };
469                if let Some(location) = items.last_mut().and_then(|i| i.locations.last_mut()) {
470                    location.quantities.push(quantity);
471                }
472            }
473            "STS" => {
474                let code = seg.component_str(0, 0).filter(|s| !s.is_empty());
475                let target = items
476                    .last_mut()
477                    .and_then(|i| i.locations.last_mut())
478                    .and_then(|l| l.quantities.last_mut());
479                if let (Some(code), Some(quantity)) = (code, target) {
480                    quantity.status.push(code.to_owned());
481                }
482            }
483            "NAD" => {
484                if let (Some(item), Some(party)) = (items.last_mut(), read_party(seg)) {
485                    item.parties.push(party);
486                }
487            }
488            _ => {}
489        }
490    }
491    items
492}