Skip to main content

mako_engine/
types.rs

1//! Semantic domain type wrappers for identifiers used across all MaKo process families.
2//!
3//! All types in this module wrap `Box<str>` rather than `String` — they are
4//! **immutable** identifiers that are never mutated after construction.
5//! `Box<str>` is one pointer word smaller than `String` on the stack and avoids
6//! the extra capacity bookkeeping.
7//!
8//! ## Why newtypes instead of `String`?
9//!
10//! Domain commands and events have many identifier fields:
11//!
12//! ```text
13//! ReceiveUtilmd {
14//!     sender:        String,  // GLN
15//!     receiver:      String,  // GLN
16//!     location_id:   String,  // MaLo / EIC
17//!     document_date: String,  // YYYYMMDD
18//!     message_ref:   String,  // EDIFACT reference
19//! }
20//! ```
21//!
22//! Passing `location_id` where `sender` is expected is a compile-time no-op
23//! when all fields are `String`. Typed wrappers turn that into a type error.
24//!
25//! ## Construction
26//!
27//! All types implement `From<String>` and `From<&str>` for ergonomic
28//! construction without `.into()` gymnastics:
29//!
30//! ```rust
31//! use mako_engine::types::{MaLo, MarktpartnerCode};
32//!
33//! let malo:   MaLo             = MaLo::new("51238696012");
34//! let sender: MarktpartnerCode = MarktpartnerCode::new("9900123456789");
35//! ```
36//!
37//! ## These are wire-boundary types, not validated value objects
38//!
39//! The wrappers carry no format or check-digit validation: a message arriving
40//! over AS4 may hold a malformed identifier, and the process must be able to
41//! represent it in order to reject it with a precise, citable error. Validation
42//! belongs one layer in, where `rubo4e::identifiers::{MaloId, MeloId,
43//! MarktpartnerId}` provide the check-digit-validated domain value objects.
44//! Treat a value of this module's types as "whatever the counterparty sent",
45//! never as "a valid MaLo".
46//!
47//! ## Serde
48//!
49//! All types serialize/deserialize as plain JSON strings, keeping event
50//! payloads human-readable in SlateDB and log output.
51
52use serde::{Deserialize, Serialize};
53use std::fmt;
54
55macro_rules! domain_id {
56    (
57        $(#[$attr:meta])*
58        $name:ident,
59        $doc:literal
60    ) => {
61        $(#[$attr])*
62        #[doc = $doc]
63        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
64        #[serde(transparent)]
65        pub struct $name(Box<str>);
66
67        impl $name {
68            /// Construct a new identifier from any string-like value.
69            #[must_use]
70            pub fn new(s: impl Into<Box<str>>) -> Self {
71                Self(s.into())
72            }
73
74            /// Borrow the underlying string slice.
75            #[must_use]
76            pub fn as_str(&self) -> &str {
77                &self.0
78            }
79        }
80
81        impl fmt::Display for $name {
82            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83                f.write_str(&self.0)
84            }
85        }
86
87        impl From<String> for $name {
88            fn from(s: String) -> Self {
89                Self(s.into_boxed_str())
90            }
91        }
92
93        impl From<&str> for $name {
94            fn from(s: &str) -> Self {
95                Self(s.into())
96            }
97        }
98
99        impl From<$name> for String {
100            fn from(id: $name) -> Self {
101                id.0.into()
102            }
103        }
104
105        impl AsRef<str> for $name {
106            fn as_ref(&self) -> &str {
107                &self.0
108            }
109        }
110    };
111}
112
113domain_id!(
114    /// Marktlokations-ID (MaLo).
115    ///
116    /// Identifies a supply point for electricity or gas in the German energy
117    /// market. A well-formed MaLo-ID is **11 digits**, the eleventh being a BDEW
118    /// check digit over the first ten.
119    ///
120    /// This wrapper does **not** verify that: it is the wire-boundary type, and
121    /// an inbound message carrying a malformed ID must still be representable so
122    /// the process can reject it with a precise error. Use
123    /// `rubo4e::identifiers::MaloId` for the validated domain value.
124    MaLo,
125    "Marktlokations-ID — supply point identifier (11 digits incl. BDEW check digit)"
126);
127
128domain_id!(
129    /// Messlokations-ID (MeLo).
130    ///
131    /// Identifies a metering point in the WiM (Wechselprozesse im Messwesen)
132    /// process family. Distinct from a MaLo — one supply point may have
133    /// multiple metering points.
134    ///
135    /// A well-formed MeLo-ID is **33 characters**: a two-letter ISO 3166-1
136    /// country code followed by 31 alphanumerics. Unvalidated here for the same
137    /// wire-boundary reason as [`MaLo`]; the validated value object is
138    /// `rubo4e::identifiers::MeloId`.
139    MeLo,
140    "Messlokations-ID — metering point identifier (33 chars, country-code prefixed)"
141);
142
143domain_id!(
144    /// Market-participant identifier (Marktpartner-Code).
145    ///
146    /// Identifies a trading partner in the German energy market. Three code
147    /// schemes are in active use:
148    ///
149    /// | Scheme | Digits | EDIFACT DE 3055 | Typical holders |
150    /// |--------|--------|-----------------|-----------------|
151    /// | **BDEW code** | 13 numeric | `"293"` | Suppliers (LFN), DSOs (NB/VNB), MSBs, BKVs — the dominant scheme |
152    /// | **GLN** (GS1) | 13 numeric | `"9"` | Global GS1 scheme; rare in German MaKo |
153    /// | **EIC** (ENTSO-E) | 16 alphanumeric | `"305"` | TSOs (ÜNB), Regelzonen, cross-border |
154    ///
155    /// Used as `sender` and `receiver` in EDIFACT message headers and as
156    /// domain party identifiers in all MaKo process commands. The numeric
157    /// value is stored without the agency qualifier — use
158    /// `edi_energy::AgencyCode` when rendering outbound NAD segments.
159    MarktpartnerCode,
160    "Marktpartner-Code — BDEW code (293), GS1 GLN (9), or EIC (305) market-participant identifier"
161);
162
163domain_id!(
164    /// EDIFACT message reference.
165    ///
166    /// Corresponds to the BGM/C106 reference number in UTILMD, APERAK,
167    /// MSCONS, and REMADV messages. Used to correlate responses back to the
168    /// originating message and to detect duplicate deliveries.
169    MessageRef,
170    "EDIFACT message reference (BGM/C106 document number)"
171);
172
173domain_id!(
174    /// Geräte-ID / Zählernummer.
175    ///
176    /// Identifies a physical metering device in the WiM Gerätewechsel
177    /// process. Assigned by the Messstellenbetreiber; format varies by
178    /// device manufacturer.
179    DeviceId,
180    "Geräte-ID — physical metering device identifier (Zählernummer)"
181);
182
183domain_id!(
184    /// Bilanzkreisverantwortlicher-ID (BKV).
185    ///
186    /// Identifies the balance circle responsible party in MaBiS billing
187    /// processes. Used in Prüfmitteilung and billing settlement messages.
188    BkvId,
189    "Bilanzkreisverantwortlicher-ID — balance circle responsible party"
190);
191
192domain_id!(
193    /// Übertragungsnetzbetreiber-ID (ÜNB).
194    ///
195    /// Identifies the transmission grid operator. Kept for use in contexts
196    /// outside MaBiS billing (e.g. GaBi Gas, Redispatch).
197    UenbId,
198    "Übertragungsnetzbetreiber-ID — transmission grid operator identifier"
199);
200
201domain_id!(
202    /// Bilanzkoordinator-ID (BIKO).
203    ///
204    /// Identifies the Bilanzkoordinator in MaBiS processes. The BIKO is the
205    /// central actor in Bilanzkreisabrechnung Strom: it assigns every
206    /// Datenstatus (BK6-24-174 Anlage 3 Kap. 3.8.3), sends the
207    /// `Abrechnungssummenzeitreihe` to BKV, NB and ÜNB, and forwards the
208    /// `Prüfmitteilung` between them.
209    ///
210    /// The Prüfmitteilung itself carries **no Frist** — Kap. 9.8.2 Nr. 1 leaves
211    /// the cell empty and the receiving party „kann" answer. What bounds it is
212    /// the clearing window of Kap. 3.10 Tabelle 2. The two genuine 1-Werktag
213    /// obligations are the BIKO's own: forwarding a Prüfmitteilung (Kap. 9.8.2
214    /// Nr. 3) and dispatching the Datenstatus (Kap. 9.9.2 Nr. 1). See
215    /// `mako_mabis::fristen`.
216    BikoId,
217    "Bilanzkoordinator-ID — balance coordinator identifier (BIKO)"
218);
219
220domain_id!(
221    /// Abrechnungszeitraum (billing period).
222    ///
223    /// Represents the billing period as a string in `YYYYMM` or `YYYYMMDD–YYYYMMDD`
224    /// format, depending on the context and AHB version. Kept as an opaque
225    /// string rather than a date range to avoid coupling to a specific calendar
226    /// representation.
227    BillingPeriod,
228    "Abrechnungszeitraum — billing period identifier string"
229);
230
231// ── MeteredInterval ───────────────────────────────────────────────────────────
232
233/// One decoded metered value from an MSCONS `SG9`/`SG10` group.
234///
235/// # Why this lives here
236///
237/// Both MSCONS-receiving workflows — `mako-gpke`'s Strom Messwerte and
238/// `mako-geli-gas`'s Gas Messdaten — need to carry the values they received to
239/// the ERP, and `makod` decodes them from one `MsconsMessage` shape for both.
240/// A type per crate would be two decoders that drift; this is the one both
241/// depend on already.
242///
243/// # What is kept raw
244///
245/// `qualifier` is `SG10 QTY` DE 6063 exactly as it arrived — `220` Wahrer Wert,
246/// `67` Ersatzwert, `Z18` Vorläufiger Wert (MSCONS AHB 3.1g §11.2). The
247/// translation into the quality vocabulary the ERP and `edmd` share is
248/// [`MeteredInterval::quality`], applied when the payload is built. An inbound
249/// value stays representable so a message carrying an unexpected qualifier can
250/// still be recorded and refused, rather than being coerced at the boundary.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct MeteredInterval {
254    /// `SG9 PIA` DE 7140 — the OBIS register the value belongs to.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub obis_code: Option<String>,
257    /// `SG6 LOC+172` DE 3225 — the Messlokation, when the series names one.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub melo_id: Option<String>,
260    /// `SG10 DTM+163` — interval start, RFC 3339.
261    pub dtm_from: String,
262    /// `SG10 DTM+164` — interval end, RFC 3339.
263    pub dtm_to: String,
264    /// `SG10 QTY` DE 6060 — the quantity, as written on the wire.
265    ///
266    /// A string, not a float: `[906]` allows three decimal places and a
267    /// settlement quantity must not acquire a binary rounding error in transit.
268    pub quantity: String,
269    /// `SG10 QTY` DE 6411 — the unit, normally `KWH`.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub unit: Option<String>,
272    /// `SG10 QTY` DE 6063, verbatim.
273    pub qualifier: String,
274}
275
276impl MeteredInterval {
277    /// The MSCONS DE 6063 qualifier in the quality vocabulary the ERP event
278    /// and `edmd` share.
279    ///
280    /// | DE 6063 | MSCONS AHB 3.1g | Quality |
281    /// |---|---|---|
282    /// | `220` | Wahrer Wert | `MEASURED` |
283    /// | `67` | Ersatzwert | `SUBSTITUTED` |
284    /// | `Z18` | Vorläufiger Wert | `PRELIMINARY` |
285    ///
286    /// Anything else is `UNKNOWN`, which downstream treats as not billable —
287    /// the safe reading for a qualifier this release does not know.
288    #[must_use]
289    pub fn quality(&self) -> &'static str {
290        match self.qualifier.as_str() {
291            "220" => "MEASURED",
292            "67" => "SUBSTITUTED",
293            "Z18" => "PRELIMINARY",
294            _ => "UNKNOWN",
295        }
296    }
297}
298
299// ── Pruefidentifikator ────────────────────────────────────────────────────────
300
301/// A validated BDEW process-type code (Prüfidentifikator, PID).
302///
303/// Prüfidentifikatoren are 5-digit decimal codes in the range `10000–99999`
304/// that identify the business process variant of an EDI@Energy message
305/// (e.g. `55001` for GPKE Lieferbeginn, `11001` for WiM Zählerstand).
306///
307/// Energy commodity — used for commodity-aware PID routing.
308///
309/// Several AHBs are Sparte-neutral, so the same Prüfidentifikator carries a
310/// Strom and a Gas process: INSRPT 23001/23003/23004/23008, the WiM ORDERS
311/// 17001/17002/17009 and their ORDRSP answers, REQOTE 35001 / QUOTES 15001, and
312/// the IFTSTA Statusmeldungen. The Sparte is not in the message body — it is
313/// the Sparte of the **interchange recipient's MP-ID** (BDEW Allgemeine
314/// Festlegungen §2.13: every MP-ID covers exactly one Sparte). The ingest layer
315/// resolves it there and supplies it to [`PidRouter::route_with_sparte`].
316///
317/// The two Sparten differ in more than routing:
318///
319/// | | Strom | Gas |
320/// |---|---|---|
321/// | APERAK | positive **and** negative; 45 min for UTILMD/ORDERS, sonst nächster Werktag 12:00 | **negative only**; nächster Werktag 12:00 (Folgeprozess) / 3 WT (Initialprozess) |
322/// | CONTRL | only on a syntactically broken APERAK | on **every** APERAK |
323/// | WiM Zuordnungszeitpunkt | 00:00 Uhr | **06:00 Uhr** (Gastag) |
324/// | WiM Antwort-Codeliste | `S_00xx` | `G_00xx` |
325///
326/// The WiM *Antwortfristen* are identical in both (3 / 5 / 7 / 1 Werktage).
327///
328/// [`PidRouter::route_with_sparte`]: crate::pid_router::PidRouter::route_with_sparte
329#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
330#[serde(rename_all = "lowercase")]
331pub enum Sparte {
332    /// Electricity (Strom) — BK6-24-174 GPKE, BK6-22-024 WiM Strom.
333    Strom,
334    /// Natural gas (Gas) — BK7-24-01-009 GeLi Gas 3.0 / AWH WiM Gas 2.0.
335    Gas,
336}
337
338impl fmt::Display for Sparte {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        match self {
341            Self::Strom => write!(f, "Strom"),
342            Self::Gas => write!(f, "Gas"),
343        }
344    }
345}
346
347/// # Serde representation
348///
349/// Serialises as a plain JSON number (`u32`), matching the wire format of
350/// `edi_energy::Pruefidentifikator` (which is also `#[serde(transparent)]`
351/// over `u32`). Stored event payloads are therefore fully compatible with both
352/// representations — no migration needed.
353///
354/// # Why this lives in `mako-engine` and not `edi-energy`
355///
356/// Domain event structs and workflow state must only depend on `mako-engine`,
357/// not on the stateless parsing library `edi-energy`. Moving the PID type here
358/// removes the `edi-energy` dependency from all domain crates.
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
360#[serde(transparent)]
361pub struct Pruefidentifikator(u32);
362
363impl Pruefidentifikator {
364    /// The inclusive lower bound of the valid PID range.
365    pub const MIN: u32 = 10_000;
366    /// The inclusive upper bound of the valid PID range.
367    pub const MAX: u32 = 99_999;
368
369    /// Construct a `Pruefidentifikator`, validating that `code` is in range.
370    ///
371    /// # Errors
372    ///
373    /// Returns an error string if `code < 10000` or `code > 99999`.
374    pub fn new(code: u32) -> Result<Self, String> {
375        if (Self::MIN..=Self::MAX).contains(&code) {
376            Ok(Self(code))
377        } else {
378            Err(format!(
379                "invalid Pruefidentifikator {code}: must be a 5-digit code in 10000–99999"
380            ))
381        }
382    }
383
384    /// Construct a `Pruefidentifikator` in const context.
385    ///
386    /// Intended for typed PID constants:
387    ///
388    /// ```rust
389    /// use mako_engine::types::Pruefidentifikator;
390    /// const BESTELLUNG_PID: Pruefidentifikator = Pruefidentifikator::const_new(17007);
391    /// ```
392    ///
393    /// # Panics
394    ///
395    /// Panics at **compile time** (const evaluation) when `code` is outside
396    /// `10000–99999`, so an out-of-range constant cannot build.
397    #[must_use]
398    pub const fn const_new(code: u32) -> Self {
399        assert!(
400            code >= Self::MIN && code <= Self::MAX,
401            "invalid Pruefidentifikator: must be a 5-digit code in 10000-99999"
402        );
403        Self(code)
404    }
405
406    /// Returns the numeric code.
407    #[must_use]
408    pub const fn as_u32(self) -> u32 {
409        self.0
410    }
411}
412
413impl fmt::Display for Pruefidentifikator {
414    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
415        write!(f, "{:05}", self.0)
416    }
417}
418
419impl std::str::FromStr for Pruefidentifikator {
420    type Err = String;
421
422    fn from_str(s: &str) -> Result<Self, Self::Err> {
423        s.parse::<u32>()
424            .map_err(|_| format!("Pruefidentifikator is not a decimal integer: {s:?}"))
425            .and_then(Self::new)
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use serde_json::json;
433
434    #[test]
435    fn malo_roundtrip_display_and_serde() {
436        let m = MaLo::new("DE00123456789012345678901234567890");
437        assert_eq!(m.to_string(), "DE00123456789012345678901234567890");
438        let v = serde_json::to_value(&m).unwrap();
439        assert_eq!(v, json!("DE00123456789012345678901234567890"));
440        let back: MaLo = serde_json::from_value(v).unwrap();
441        assert_eq!(back, m);
442    }
443
444    #[test]
445    fn from_string_and_str() {
446        let from_string: MarktpartnerCode = MarktpartnerCode::from(String::from("4012345000009"));
447        let from_str: MarktpartnerCode = MarktpartnerCode::from("4012345000009");
448        assert_eq!(from_string, from_str);
449    }
450
451    #[test]
452    fn into_string() {
453        let mid = MessageRef::new("UTILMD-2025-001");
454        let s: String = mid.into();
455        assert_eq!(s, "UTILMD-2025-001");
456    }
457
458    #[test]
459    fn distinct_types_are_not_interchangeable() {
460        // This test is a compile-time proof: the following would NOT compile:
461        // let malo: MaLo = MeLo::new("X");
462        // let _: MaLo = MarktpartnerCode::new("X");
463        let malo_val = MaLo::new("A");
464        let messlokation = MeLo::new("A");
465        // Different types even though same inner value:
466        let _: MaLo = malo_val;
467        let _: MeLo = messlokation;
468    }
469
470    #[test]
471    fn pruefidentifikator_const_new_and_structural_match() {
472        const BESTELLUNG: Pruefidentifikator = Pruefidentifikator::const_new(17007);
473        const ABBESTELLUNG: Pruefidentifikator = Pruefidentifikator::const_new(17008);
474        assert_eq!(BESTELLUNG.as_u32(), 17007);
475        // Typed constants must be usable directly as match patterns
476        // (structural equality via derived PartialEq/Eq on a plain u32 field).
477        let got = Pruefidentifikator::new(17008).unwrap();
478        let label = match got {
479            BESTELLUNG => "bestellung",
480            ABBESTELLUNG => "abbestellung",
481            _ => "other",
482        };
483        assert_eq!(label, "abbestellung");
484    }
485
486    #[test]
487    fn as_str_and_as_ref() {
488        let g = MarktpartnerCode::new("4012345000009");
489        assert_eq!(g.as_str(), "4012345000009");
490        let s: &str = g.as_ref();
491        assert_eq!(s, "4012345000009");
492    }
493}