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}