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("51238696780");
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 calculates and sends
206 /// the `Abrechnungssummenzeitreihe` to BKV, NB, and ÜNB, and receives
207 /// the `Prüfmitteilung` back from BKV. The BKV must respond with a
208 /// Prüfmitteilung within **1 Werktag** of receiving the Abrechnungs-
209 /// summenzeitreihe (MaBiS BK6-24-174, §13.8).
210 BikoId,
211 "Bilanzkoordinator-ID — balance coordinator identifier (BIKO)"
212);
213
214domain_id!(
215 /// Abrechnungszeitraum (billing period).
216 ///
217 /// Represents the billing period as a string in `YYYYMM` or `YYYYMMDD–YYYYMMDD`
218 /// format, depending on the context and AHB version. Kept as an opaque
219 /// string rather than a date range to avoid coupling to a specific calendar
220 /// representation.
221 BillingPeriod,
222 "Abrechnungszeitraum — billing period identifier string"
223);
224
225// ── Pruefidentifikator ────────────────────────────────────────────────────────
226
227/// A validated BDEW process-type code (Prüfidentifikator, PID).
228///
229/// Prüfidentifikatoren are 5-digit decimal codes in the range `10000–99999`
230/// that identify the business process variant of an EDI@Energy message
231/// (e.g. `55001` for GPKE Lieferbeginn, `11001` for WiM Zählerstand).
232///
233/// Energy commodity — used for commodity-aware PID routing.
234///
235/// INSRPT PIDs 23001/23003/23004/23008 are shared between WiM Strom (5 Werktage
236/// APERAK Frist) and WiM Gas (10 Werktage APERAK Frist). When the ingest layer
237/// can determine the commodity of an incoming message — for example from the
238/// MaLo cache — it supplies a `Sparte` to [`PidRouter::route_with_sparte`] so
239/// that the correct workflow is selected.
240///
241/// [`PidRouter::route_with_sparte`]: crate::pid_router::PidRouter::route_with_sparte
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
243#[serde(rename_all = "lowercase")]
244pub enum Sparte {
245 /// Electricity (Strom) — APERAK Frist 5 Werktage (WiM Strom, BK6-24-174).
246 Strom,
247 /// Natural gas (Gas) — APERAK Frist 10 Werktage (WiM Gas, BK7-24-01-009).
248 Gas,
249}
250
251impl fmt::Display for Sparte {
252 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253 match self {
254 Self::Strom => write!(f, "Strom"),
255 Self::Gas => write!(f, "Gas"),
256 }
257 }
258}
259
260/// # Serde representation
261///
262/// Serialises as a plain JSON number (`u32`), matching the wire format of
263/// `edi_energy::Pruefidentifikator` (which is also `#[serde(transparent)]`
264/// over `u32`). Stored event payloads are therefore fully compatible with both
265/// representations — no migration needed.
266///
267/// # Why this lives in `mako-engine` and not `edi-energy`
268///
269/// Domain event structs and workflow state must only depend on `mako-engine`,
270/// not on the stateless parsing library `edi-energy`. Moving the PID type here
271/// removes the `edi-energy` dependency from all domain crates.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
273#[serde(transparent)]
274pub struct Pruefidentifikator(u32);
275
276impl Pruefidentifikator {
277 /// The inclusive lower bound of the valid PID range.
278 pub const MIN: u32 = 10_000;
279 /// The inclusive upper bound of the valid PID range.
280 pub const MAX: u32 = 99_999;
281
282 /// Construct a `Pruefidentifikator`, validating that `code` is in range.
283 ///
284 /// # Errors
285 ///
286 /// Returns an error string if `code < 10000` or `code > 99999`.
287 pub fn new(code: u32) -> Result<Self, String> {
288 if (Self::MIN..=Self::MAX).contains(&code) {
289 Ok(Self(code))
290 } else {
291 Err(format!(
292 "invalid Pruefidentifikator {code}: must be a 5-digit code in 10000–99999"
293 ))
294 }
295 }
296
297 /// Construct a `Pruefidentifikator` in const context.
298 ///
299 /// Intended for typed PID constants:
300 ///
301 /// ```rust
302 /// use mako_engine::types::Pruefidentifikator;
303 /// const BESTELLUNG_PID: Pruefidentifikator = Pruefidentifikator::const_new(17007);
304 /// ```
305 ///
306 /// # Panics
307 ///
308 /// Panics at **compile time** (const evaluation) when `code` is outside
309 /// `10000–99999`, so an out-of-range constant cannot build.
310 #[must_use]
311 pub const fn const_new(code: u32) -> Self {
312 assert!(
313 code >= Self::MIN && code <= Self::MAX,
314 "invalid Pruefidentifikator: must be a 5-digit code in 10000-99999"
315 );
316 Self(code)
317 }
318
319 /// Returns the numeric code.
320 #[must_use]
321 pub const fn as_u32(self) -> u32 {
322 self.0
323 }
324}
325
326impl fmt::Display for Pruefidentifikator {
327 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328 write!(f, "{:05}", self.0)
329 }
330}
331
332impl std::str::FromStr for Pruefidentifikator {
333 type Err = String;
334
335 fn from_str(s: &str) -> Result<Self, Self::Err> {
336 s.parse::<u32>()
337 .map_err(|_| format!("Pruefidentifikator is not a decimal integer: {s:?}"))
338 .and_then(Self::new)
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use serde_json::json;
346
347 #[test]
348 fn malo_roundtrip_display_and_serde() {
349 let m = MaLo::new("DE00123456789012345678901234567890");
350 assert_eq!(m.to_string(), "DE00123456789012345678901234567890");
351 let v = serde_json::to_value(&m).unwrap();
352 assert_eq!(v, json!("DE00123456789012345678901234567890"));
353 let back: MaLo = serde_json::from_value(v).unwrap();
354 assert_eq!(back, m);
355 }
356
357 #[test]
358 fn from_string_and_str() {
359 let from_string: MarktpartnerCode = MarktpartnerCode::from(String::from("4012345000009"));
360 let from_str: MarktpartnerCode = MarktpartnerCode::from("4012345000009");
361 assert_eq!(from_string, from_str);
362 }
363
364 #[test]
365 fn into_string() {
366 let mid = MessageRef::new("UTILMD-2025-001");
367 let s: String = mid.into();
368 assert_eq!(s, "UTILMD-2025-001");
369 }
370
371 #[test]
372 fn distinct_types_are_not_interchangeable() {
373 // This test is a compile-time proof: the following would NOT compile:
374 // let malo: MaLo = MeLo::new("X");
375 // let _: MaLo = MarktpartnerCode::new("X");
376 let malo_val = MaLo::new("A");
377 let messlokation = MeLo::new("A");
378 // Different types even though same inner value:
379 let _: MaLo = malo_val;
380 let _: MeLo = messlokation;
381 }
382
383 #[test]
384 fn pruefidentifikator_const_new_and_structural_match() {
385 const BESTELLUNG: Pruefidentifikator = Pruefidentifikator::const_new(17007);
386 const ABBESTELLUNG: Pruefidentifikator = Pruefidentifikator::const_new(17008);
387 assert_eq!(BESTELLUNG.as_u32(), 17007);
388 // Typed constants must be usable directly as match patterns
389 // (structural equality via derived PartialEq/Eq on a plain u32 field).
390 let got = Pruefidentifikator::new(17008).unwrap();
391 let label = match got {
392 BESTELLUNG => "bestellung",
393 ABBESTELLUNG => "abbestellung",
394 _ => "other",
395 };
396 assert_eq!(label, "abbestellung");
397 }
398
399 #[test]
400 fn as_str_and_as_ref() {
401 let g = MarktpartnerCode::new("4012345000009");
402 assert_eq!(g.as_str(), "4012345000009");
403 let s: &str = g.as_ref();
404 assert_eq!(s, "4012345000009");
405 }
406}