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("DE00123456789012345678901234567890");
34//! let sender: MarktpartnerCode = MarktpartnerCode::new("9900123456789");
35//! ```
36//!
37//! ## Serde
38//!
39//! All types serialize/deserialize as plain JSON strings, keeping event
40//! payloads human-readable in SlateDB and log output.
41
42use serde::{Deserialize, Serialize};
43use std::fmt;
44
45macro_rules! domain_id {
46 (
47 $(#[$attr:meta])*
48 $name:ident,
49 $doc:literal
50 ) => {
51 $(#[$attr])*
52 #[doc = $doc]
53 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
54 #[serde(transparent)]
55 pub struct $name(Box<str>);
56
57 impl $name {
58 /// Construct a new identifier from any string-like value.
59 #[must_use]
60 pub fn new(s: impl Into<Box<str>>) -> Self {
61 Self(s.into())
62 }
63
64 /// Borrow the underlying string slice.
65 #[must_use]
66 pub fn as_str(&self) -> &str {
67 &self.0
68 }
69 }
70
71 impl fmt::Display for $name {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.write_str(&self.0)
74 }
75 }
76
77 impl From<String> for $name {
78 fn from(s: String) -> Self {
79 Self(s.into_boxed_str())
80 }
81 }
82
83 impl From<&str> for $name {
84 fn from(s: &str) -> Self {
85 Self(s.into())
86 }
87 }
88
89 impl From<$name> for String {
90 fn from(id: $name) -> Self {
91 id.0.into()
92 }
93 }
94
95 impl AsRef<str> for $name {
96 fn as_ref(&self) -> &str {
97 &self.0
98 }
99 }
100 };
101}
102
103domain_id!(
104 /// Marktlokations-ID (MaLo).
105 ///
106 /// Identifies a supply point for electricity or gas in the German energy
107 /// market. EIC format (33-char) or legacy 13-digit format; exact format
108 /// depends on the process family and format version.
109 MaLo,
110 "Marktlokations-ID — supply point identifier (EIC / MaLo format)"
111);
112
113domain_id!(
114 /// Messlokations-ID (MeLo).
115 ///
116 /// Identifies a metering point in the WiM (Wechselprozesse im Messwesen)
117 /// process family. Distinct from a MaLo — one supply point may have
118 /// multiple metering points.
119 MeLo,
120 "Messlokations-ID — metering point identifier"
121);
122
123domain_id!(
124 /// Market-participant identifier (Marktpartner-Code).
125 ///
126 /// Identifies a trading partner in the German energy market. Three code
127 /// schemes are in active use:
128 ///
129 /// | Scheme | Digits | EDIFACT DE 3055 | Typical holders |
130 /// |--------|--------|-----------------|-----------------|
131 /// | **BDEW code** | 13 numeric | `"293"` | Suppliers (LFN), DSOs (NB/VNB), MSBs, BKVs — the dominant scheme |
132 /// | **GLN** (GS1) | 13 numeric | `"9"` | Global GS1 scheme; rare in German MaKo |
133 /// | **EIC** (ENTSO-E) | 16 alphanumeric | `"305"` | TSOs (ÜNB), Regelzonen, cross-border |
134 ///
135 /// Used as `sender` and `receiver` in EDIFACT message headers and as
136 /// domain party identifiers in all MaKo process commands. The numeric
137 /// value is stored without the agency qualifier — use
138 /// `edi_energy::AgencyCode` when rendering outbound NAD segments.
139 MarktpartnerCode,
140 "Marktpartner-Code — BDEW code (293), GS1 GLN (9), or EIC (305) market-participant identifier"
141);
142
143domain_id!(
144 /// EDIFACT message reference.
145 ///
146 /// Corresponds to the BGM/C106 reference number in UTILMD, APERAK,
147 /// MSCONS, and REMADV messages. Used to correlate responses back to the
148 /// originating message and to detect duplicate deliveries.
149 MessageRef,
150 "EDIFACT message reference (BGM/C106 document number)"
151);
152
153domain_id!(
154 /// Geräte-ID / Zählernummer.
155 ///
156 /// Identifies a physical metering device in the WiM Gerätewechsel
157 /// process. Assigned by the Messstellenbetreiber; format varies by
158 /// device manufacturer.
159 DeviceId,
160 "Geräte-ID — physical metering device identifier (Zählernummer)"
161);
162
163domain_id!(
164 /// Bilanzkreisverantwortlicher-ID (BKV).
165 ///
166 /// Identifies the balance circle responsible party in MaBiS billing
167 /// processes. Used in Prüfmitteilung and billing settlement messages.
168 BkvId,
169 "Bilanzkreisverantwortlicher-ID — balance circle responsible party"
170);
171
172domain_id!(
173 /// Übertragungsnetzbetreiber-ID (ÜNB).
174 ///
175 /// Identifies the transmission grid operator. Kept for use in contexts
176 /// outside MaBiS billing (e.g. GaBi Gas, Redispatch).
177 UenbId,
178 "Übertragungsnetzbetreiber-ID — transmission grid operator identifier"
179);
180
181domain_id!(
182 /// Bilanzkoordinator-ID (BIKO).
183 ///
184 /// Identifies the Bilanzkoordinator in MaBiS processes. The BIKO is the
185 /// central actor in Bilanzkreisabrechnung Strom: it calculates and sends
186 /// the `Abrechnungssummenzeitreihe` to BKV, NB, and ÜNB, and receives
187 /// the `Prüfmitteilung` back from BKV. The BKV must respond with a
188 /// Prüfmitteilung within **1 Werktag** of receiving the Abrechnungs-
189 /// summenzeitreihe (MaBiS BK6-24-174, §13.8).
190 BikoId,
191 "Bilanzkoordinator-ID — balance coordinator identifier (BIKO)"
192);
193
194domain_id!(
195 /// Abrechnungszeitraum (billing period).
196 ///
197 /// Represents the billing period as a string in `YYYYMM` or `YYYYMMDD–YYYYMMDD`
198 /// format, depending on the context and AHB version. Kept as an opaque
199 /// string rather than a date range to avoid coupling to a specific calendar
200 /// representation.
201 BillingPeriod,
202 "Abrechnungszeitraum — billing period identifier string"
203);
204
205// ── Pruefidentifikator ────────────────────────────────────────────────────────
206
207/// A validated BDEW process-type code (Prüfidentifikator, PID).
208///
209/// Prüfidentifikatoren are 5-digit decimal codes in the range `10000–99999`
210/// that identify the business process variant of an EDI@Energy message
211/// (e.g. `55001` for GPKE Lieferbeginn, `11001` for WiM Zählerstand).
212///
213/// Energy commodity — used for commodity-aware PID routing.
214///
215/// INSRPT PIDs 23001/23003/23004/23008 are shared between WiM Strom (5 Werktage
216/// APERAK Frist) and WiM Gas (10 Werktage APERAK Frist). When the ingest layer
217/// can determine the commodity of an incoming message — for example from the
218/// MaLo cache — it supplies a `Sparte` to [`PidRouter::route_with_sparte`] so
219/// that the correct workflow is selected.
220///
221/// [`PidRouter::route_with_sparte`]: crate::pid_router::PidRouter::route_with_sparte
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
223#[serde(rename_all = "lowercase")]
224pub enum Sparte {
225 /// Electricity (Strom) — APERAK Frist 5 Werktage (WiM Strom, BK6-24-174).
226 Strom,
227 /// Natural gas (Gas) — APERAK Frist 10 Werktage (WiM Gas, BK7-24-01-009).
228 Gas,
229}
230
231impl fmt::Display for Sparte {
232 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233 match self {
234 Self::Strom => write!(f, "Strom"),
235 Self::Gas => write!(f, "Gas"),
236 }
237 }
238}
239
240/// # Serde representation
241///
242/// Serialises as a plain JSON number (`u32`), matching the wire format of
243/// `edi_energy::Pruefidentifikator` (which is also `#[serde(transparent)]`
244/// over `u32`). Stored event payloads are therefore fully compatible with both
245/// representations — no migration needed.
246///
247/// # Why this lives in `mako-engine` and not `edi-energy`
248///
249/// Domain event structs and workflow state must only depend on `mako-engine`,
250/// not on the stateless parsing library `edi-energy`. Moving the PID type here
251/// removes the `edi-energy` dependency from all domain crates.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
253#[serde(transparent)]
254pub struct Pruefidentifikator(u32);
255
256impl Pruefidentifikator {
257 /// The inclusive lower bound of the valid PID range.
258 pub const MIN: u32 = 10_000;
259 /// The inclusive upper bound of the valid PID range.
260 pub const MAX: u32 = 99_999;
261
262 /// Construct a `Pruefidentifikator`, validating that `code` is in range.
263 ///
264 /// # Errors
265 ///
266 /// Returns an error string if `code < 10000` or `code > 99999`.
267 pub fn new(code: u32) -> Result<Self, String> {
268 if (Self::MIN..=Self::MAX).contains(&code) {
269 Ok(Self(code))
270 } else {
271 Err(format!(
272 "invalid Pruefidentifikator {code}: must be a 5-digit code in 10000–99999"
273 ))
274 }
275 }
276
277 /// Returns the numeric code.
278 #[must_use]
279 pub fn as_u32(self) -> u32 {
280 self.0
281 }
282}
283
284impl fmt::Display for Pruefidentifikator {
285 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286 write!(f, "{:05}", self.0)
287 }
288}
289
290impl std::str::FromStr for Pruefidentifikator {
291 type Err = String;
292
293 fn from_str(s: &str) -> Result<Self, Self::Err> {
294 s.parse::<u32>()
295 .map_err(|_| format!("Pruefidentifikator is not a decimal integer: {s:?}"))
296 .and_then(Self::new)
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use serde_json::json;
304
305 #[test]
306 fn malo_roundtrip_display_and_serde() {
307 let m = MaLo::new("DE00123456789012345678901234567890");
308 assert_eq!(m.to_string(), "DE00123456789012345678901234567890");
309 let v = serde_json::to_value(&m).unwrap();
310 assert_eq!(v, json!("DE00123456789012345678901234567890"));
311 let back: MaLo = serde_json::from_value(v).unwrap();
312 assert_eq!(back, m);
313 }
314
315 #[test]
316 fn from_string_and_str() {
317 let from_string: MarktpartnerCode = MarktpartnerCode::from(String::from("4012345000009"));
318 let from_str: MarktpartnerCode = MarktpartnerCode::from("4012345000009");
319 assert_eq!(from_string, from_str);
320 }
321
322 #[test]
323 fn into_string() {
324 let mid = MessageRef::new("UTILMD-2025-001");
325 let s: String = mid.into();
326 assert_eq!(s, "UTILMD-2025-001");
327 }
328
329 #[test]
330 fn distinct_types_are_not_interchangeable() {
331 // This test is a compile-time proof: the following would NOT compile:
332 // let malo: MaLo = MeLo::new("X");
333 // let _: MaLo = MarktpartnerCode::new("X");
334 let malo_val = MaLo::new("A");
335 let messlokation = MeLo::new("A");
336 // Different types even though same inner value:
337 let _: MaLo = malo_val;
338 let _: MeLo = messlokation;
339 }
340
341 #[test]
342 fn as_str_and_as_ref() {
343 let g = MarktpartnerCode::new("4012345000009");
344 assert_eq!(g.as_str(), "4012345000009");
345 let s: &str = g.as_ref();
346 assert_eq!(s, "4012345000009");
347 }
348}