Skip to main content

edi_energy/messages/
utilmd.rs

1use edifact_rs::{
2    EdifactDeserialize, EdifactSerialize, EventEmitter, OwnedSegment, ProfileRulePack,
3    ValidationIssue, ValidationSeverity,
4};
5
6use crate::{
7    MessageType,
8    messages::{
9        core::MessageCore,
10        segments::{
11            Bgm, Dtm, Ftx, Ide, Loc, Nad, Rff, Sts, collect_dtm, find_bgm, find_nad,
12            try_deserialize,
13        },
14    },
15};
16
17// ── Segment group types ───────────────────────────────────────────────────────
18
19/// A header-section reference group (UTILMD SG1: RFF + optional DTM).
20///
21/// Carries the Pruefidentifikator reference and similar header references.
22#[derive(Debug, Clone)]
23#[non_exhaustive]
24pub struct UtilmdReference {
25    /// RFF — reference qualifier and identifier.
26    pub rff: Rff,
27    /// DTM — validity date / version for this reference (optional).
28    pub dtm: Vec<Dtm>,
29}
30
31/// A per-metering-point transaction group (UTILMD SG4: IDE + nested segments).
32///
33/// Each instance represents one grid-connection or metering-point process
34/// (e.g. supplier switch, deregistration) within the message.
35#[derive(Debug, Clone)]
36#[non_exhaustive]
37pub struct UtilmdTransaction {
38    /// IDE — object / process identifier (e.g. metering-point ID).
39    pub ide: Ide,
40    /// DTM — date/time segments scoped to this transaction.
41    pub dtm: Vec<Dtm>,
42    /// LOC — location information for this transaction (e.g. grid connection
43    /// area), if present.
44    pub loc: Option<Loc>,
45    /// RFF — references related to this transaction (e.g. contract number).
46    pub references: Vec<Rff>,
47    /// STS — status segments (S2.1/S2.2 only; e.g. Sperrung E07/E08).
48    pub sts: Vec<Sts>,
49    /// FTX — free-text remarks scoped to this transaction.
50    pub ftx: Vec<Ftx>,
51}
52
53// ── UtilmdMessage ─────────────────────────────────────────────────────────────
54
55/// UTILMD — Utilities Master Data message.
56///
57/// Used in the German energy market for grid-connection processes such as
58/// supplier switches, registrations, cancellations, and meter installations.
59///
60/// # Typed access
61///
62/// Commonly-used segment data is pre-extracted into public fields:
63///
64/// | Field      | Segment | DE / meaning                               |
65/// |------------|---------|---------------------------------------------|
66/// | `bgm`          | BGM     | Document code and Pruefidentifikator        |
67/// | `dtm`          | DTM+137 | Message date/time (+ other DTM variants)    |
68/// | `sender`       | NAD+MS  | Message sender (party ID, name)             |
69/// | `receiver`     | NAD+MR  | Message recipient (party ID, name)          |
70/// | `references`   | SG1/RFF | Header references (Pruefidentifikator, etc.)|
71/// | `transactions` | SG4/IDE | Per-metering-point transaction groups       |
72///
73/// The raw [`OwnedSegment`] list is available via [`segments()`][Self::segments]
74/// for any segment not covered by the typed fields.
75///
76/// # Multiple format versions
77///
78/// A single `UtilmdMessage` type covers **all registered UTILMD release
79/// versions** (e.g. `5.5.3a`, `5.5.4a`).  Version dispatch works as follows:
80///
81/// 1. The EDI@Energy release string (EDIFACT UNH element 1, component 4 —
82///    "association assigned code") is stored verbatim in `self.assoc_code()`.
83/// 2. [`validate()`][crate::EdiEnergyMessage::validate] calls
84///    [`detect_release()`][crate::EdiEnergyMessage::detect_release], which maps
85///    `assoc_code` to a [`Release`][crate::Release] and looks it up in the
86///    global [`ReleaseRegistry`][crate::registry::ReleaseRegistry].
87/// 3. Validation runs against the profile registered for **that specific
88///    release**.  Two messages with different release codes are each validated
89///    against their own profile — there is no cross-version fallback.
90/// 4. Typed field extraction is version-agnostic: EDIFACT segment structure is
91///    backward-compatible within a UTILMD track, so `bgm`, `dtm`, `sender`,
92///    `receiver`, `references`, and `transactions` are populated regardless of
93///    release version.
94///
95/// To pin validation to a specific profile regardless of the message's own
96/// release code, use
97/// [`validate_against(release)`][crate::EdiEnergyMessage::validate_against].
98#[derive(Debug, Clone)]
99pub struct UtilmdMessage {
100    pub(crate) core: MessageCore,
101    /// BGM — beginning of message.  Always present in a valid UTILMD.
102    bgm: Option<Bgm>,
103    /// DTM — message-level date/time segments.
104    dtm: Vec<Dtm>,
105    /// NAD+MS — message sender.
106    sender: Option<Nad>,
107    /// NAD+MR — message recipient.
108    receiver: Option<Nad>,
109    /// SG1 — header references (Pruefidentifikator, MMMA, etc.).
110    references: Vec<UtilmdReference>,
111    /// SG4 — per-metering-point / per-process transaction groups.
112    transactions: Vec<UtilmdTransaction>,
113}
114
115impl UtilmdMessage {
116    /// Construct from already-parsed owned segments.
117    ///
118    /// Typed fields (`bgm`, `dtm`, `sender`, `receiver`) are pre-extracted
119    /// from the segment list for convenient access.  If a segment is absent or
120    /// malformed the corresponding field is `None` / empty — the raw segments
121    /// are always authoritative for validation.
122    pub(crate) fn from_parts(
123        segments: Vec<OwnedSegment>,
124        message_ref: impl Into<Box<str>>,
125        assoc_code: impl Into<Box<str>>,
126        pruefidentifikator: Option<u32>,
127    ) -> Self {
128        // Extract typed fields inside a scoped block so the borrow on `segments`
129        // ends before it is moved into MessageCore.
130        let (bgm, dtm, sender, receiver, references, transactions) = {
131            let borrowed: Vec<edifact_rs::Segment<'_>> =
132                segments.iter().map(|s| s.as_borrowed()).collect();
133            (
134                find_bgm(&borrowed),
135                collect_dtm(&borrowed),
136                find_nad(&borrowed, "MS"),
137                find_nad(&borrowed, "MR"),
138                parse_references(&borrowed),
139                parse_transactions(&borrowed),
140            )
141        };
142        Self {
143            core: MessageCore::new(
144                segments,
145                message_ref,
146                assoc_code,
147                pruefidentifikator,
148                MessageType::Utilmd,
149            ),
150            bgm,
151            dtm,
152            sender,
153            receiver,
154            references,
155            transactions,
156        }
157    }
158
159    /// The EDI@Energy release / association code from UNH (DE 0057), e.g. `"5.5.3a"`.
160    #[must_use]
161    pub fn assoc_code(&self) -> &str {
162        &self.core.assoc_code
163    }
164
165    /// Raw parsed segments (authoritative for validation and serialization).
166    #[must_use]
167    pub fn segments(&self) -> &[OwnedSegment] {
168        &self.core.segments
169    }
170
171    /// BGM — beginning of message.  Returns `None` when the segment was absent or malformed.
172    #[must_use]
173    pub fn bgm(&self) -> Option<&Bgm> {
174        self.bgm.as_ref()
175    }
176
177    /// DTM — message-level date/time segments (before the first transaction group).
178    #[must_use]
179    pub fn dtm(&self) -> &[Dtm] {
180        &self.dtm
181    }
182
183    /// NAD+MS — message sender.  Returns `None` when absent or malformed.
184    #[must_use]
185    pub fn sender(&self) -> Option<&Nad> {
186        self.sender.as_ref()
187    }
188
189    /// NAD+MR — message recipient.  Returns `None` when absent or malformed.
190    #[must_use]
191    pub fn receiver(&self) -> Option<&Nad> {
192        self.receiver.as_ref()
193    }
194
195    /// SG1 — header references (Pruefidentifikator, MMMA, etc.).
196    #[must_use]
197    pub fn references(&self) -> &[UtilmdReference] {
198        &self.references
199    }
200
201    /// SG4 — per-metering-point / per-process transaction groups.
202    #[must_use]
203    pub fn transactions(&self) -> &[UtilmdTransaction] {
204        &self.transactions
205    }
206}
207
208// ── EdifactDeserialize ────────────────────────────────────────────────────────
209
210impl EdifactDeserialize for UtilmdMessage {
211    fn edifact_deserialize(
212        segments: &[edifact_rs::Segment<'_>],
213    ) -> Result<Self, edifact_rs::EdifactError> {
214        let (message_ref, assoc_code) = MessageCore::extract_unh_fields(segments)?;
215        let pid = MessageCore::extract_bgm_pid(segments);
216        let owned: Vec<OwnedSegment> = segments.iter().cloned().map(OwnedSegment::from).collect();
217        Ok(Self::from_parts(owned, message_ref, assoc_code, pid))
218    }
219}
220
221// ── EdifactSerialize ──────────────────────────────────────────────────────────
222
223impl EdifactSerialize for UtilmdMessage {
224    fn edifact_serialize<E: EventEmitter>(
225        &self,
226        emitter: &mut E,
227    ) -> Result<(), edifact_rs::EdifactError> {
228        self.core.emit_segments(emitter)
229    }
230}
231
232impl_edi_energy_message!(UtilmdMessage, sem = utilmd_semantic_pack());
233
234// ── segment group parsers ─────────────────────────────────────────────────────
235
236/// Parse SG1 reference groups (RFF + optional DTM) from the header section.
237fn parse_references(segments: &[edifact_rs::Segment<'_>]) -> Vec<UtilmdReference> {
238    // Header references appear before the first IDE segment.
239    let end = segments
240        .iter()
241        .position(|s| s.tag == "IDE")
242        .unwrap_or(segments.len());
243    let header = &segments[..end];
244
245    let mut result = Vec::new();
246    let mut i = 0;
247    while i < header.len() {
248        if header[i].tag != "RFF" {
249            i += 1;
250            continue;
251        }
252        let Some(rff) = try_deserialize::<Rff>(&header[i]) else {
253            i += 1;
254            continue;
255        };
256        let mut dtm = Vec::new();
257        let mut j = i + 1;
258        while j < header.len() && header[j].tag == "DTM" {
259            if let Some(d) = try_deserialize::<Dtm>(&header[j]) {
260                dtm.push(d);
261            }
262            j += 1;
263        }
264        result.push(UtilmdReference { rff, dtm });
265        i = j;
266    }
267    result
268}
269
270/// Parse SG4 transaction groups (IDE + nested DTM/LOC/RFF) from the message.
271///
272/// Each `IDE` starts a new [`UtilmdTransaction`].
273fn parse_transactions(segments: &[edifact_rs::Segment<'_>]) -> Vec<UtilmdTransaction> {
274    let mut result = Vec::new();
275    let mut i = 0;
276
277    while i < segments.len() {
278        if segments[i].tag != "IDE" {
279            i += 1;
280            continue;
281        }
282        let Some(ide) = try_deserialize::<Ide>(&segments[i]) else {
283            i += 1;
284            continue;
285        };
286
287        let mut dtm = Vec::new();
288        let mut loc: Option<Loc> = None;
289        let mut references = Vec::new();
290        let mut sts = Vec::new();
291        let mut ftx = Vec::new();
292        let mut j = i + 1;
293
294        while j < segments.len() && segments[j].tag != "IDE" && segments[j].tag != "UNT" {
295            match segments[j].tag {
296                "DTM" => {
297                    if let Some(d) = try_deserialize::<Dtm>(&segments[j]) {
298                        dtm.push(d);
299                    }
300                }
301                "LOC" => {
302                    if loc.is_none() {
303                        loc = try_deserialize::<Loc>(&segments[j]);
304                    }
305                }
306                "RFF" => {
307                    if let Some(r) = try_deserialize::<Rff>(&segments[j]) {
308                        references.push(r);
309                    }
310                }
311                "STS" => {
312                    if let Some(s) = try_deserialize::<Sts>(&segments[j]) {
313                        sts.push(s);
314                    }
315                }
316                "FTX" => {
317                    if let Some(f) = try_deserialize::<Ftx>(&segments[j]) {
318                        ftx.push(f);
319                    }
320                }
321                _ => {}
322            }
323            j += 1;
324        }
325
326        result.push(UtilmdTransaction {
327            ide,
328            dtm,
329            loc,
330            references,
331            sts,
332            ftx,
333        });
334        i = j;
335    }
336    result
337}
338
339// ── Layer 5: UTILMD semantic rule pack ───────────────────────────────────────
340
341/// Build the UTILMD semantic rule pack (Layer 5).
342///
343/// These rules check business-level constraints that are not expressible in
344/// the structural MIG/AHB schemas:
345/// - [`rule_sem_malo_format`]: IDE market-location IDs must be exactly 11
346///   upper-case alphanumeric characters ([A-Z0-9]{11}).
347fn utilmd_semantic_pack() -> ProfileRulePack {
348    ProfileRulePack::new("UTILMD-SEM")
349        .for_message_type("UTILMD")
350        .with_stateless_rule_fn(rule_sem_malo_format)
351}
352
353/// `SEM-UTILMD-MALO-FORMAT` — Validate market/metering location IDs in IDE
354/// segments.
355///
356/// Every `IDE` segment that carries a non-empty `C206.7402` identifier must
357/// hold either a Marktlokations-ID (11 upper-case alphanumerics, `[A-Z0-9]{11}`)
358/// or a Messlokations-ID (33 characters opening with an ISO 3166-1 country
359/// code) — the two BDEW location-ID schemes.
360fn rule_sem_malo_format(segments: &[edifact_rs::Segment<'_>], issues: &mut Vec<ValidationIssue>) {
361    for seg in segments.iter().filter(|s| s.tag == "IDE") {
362        // IDE: element[0] = 7495 (type qualifier), element[1] = C206 composite.
363        // C206 component[0] = 7402 (free-form identification number).
364        let id = seg
365            .get_element(1)
366            .and_then(|e| e.get_component(0))
367            .unwrap_or("");
368        if id.is_empty() {
369            continue;
370        }
371        if !super::common::is_valid_location_id(id) {
372            issues.push(
373                ValidationIssue::new(
374                    ValidationSeverity::Error,
375                    "IDE element 7402 (C206 component 0): value is neither a \
376                     Marktlokations-ID ([A-Z0-9]{11}) nor a Messlokations-ID (33 characters)"
377                        .to_owned(),
378                )
379                .with_span(seg.span)
380                .with_rule_id("SEM-UTILMD-MALO-FORMAT")
381                .with_segment("IDE")
382                .with_suggestion(
383                    "IDE C206 must carry either an 11-character Marktlokations-ID matching \
384                     [A-Z0-9]{11} or a 33-character Messlokations-ID starting with an \
385                     ISO 3166-1 country code",
386                ),
387            );
388        }
389    }
390}