Skip to main content

mako_engine/
marktrolle.rs

1//! BDEW Rollenmodell — market-participant role configuration.
2//!
3//! The BDEW Rollenmodell für die Marktkommunikation (V2.2, January 2026) explicitly
4//! permits a single legal entity to hold multiple market roles simultaneously.
5//! Common combinations:
6//!
7//! | Combination | Regulatory basis |
8//! |---|---|
9//! | NB + gMSB | §41 MsbG — NB is grundzuständiger MSB for basic meters |
10//! | NB + BKV | Stadtwerke managing their own balance group |
11//! | NB + LF | Vertically integrated utility |
12//! | LF + BKV | Supplier managing its own balance group |
13//!
14//! ## Why role-awareness matters for PID routing
15//!
16//! Several EDIFACT PIDs are **shared across process families** and their correct
17//! inbound destination depends on which role this `makod` instance fills:
18//!
19//! | PID | ORDRSP semantics |
20//! |---|---|
21//! | 19001 (Bestellbestätigung) | → `gpke-konfiguration` when NB receiving from MSB |
22//! | 19001 (Bestellbestätigung) | → `wim-geraeteubernahme` when nMSB receiving from NB |
23//! | 19015 (Bestätigung Gerätewechselabsicht) | → `wim-geraeteubernahme` when NB receiving from nMSB |
24//! | 13003 (MSCONS Summenzeitreihe) | → `mabis-billing` when BKV receiving from BIKO |
25//! | 13003 (MSCONS Summenzeitreihe) | → MaBiS NZR handler when NB receiving from NB |
26//!
27//! By declaring which roles a `makod` instance serves, the engine can register
28//! only the PID routes that apply, preventing both silent dead-letters and
29//! accidental misrouting.
30//!
31//! ## Conflict guard
32//!
33//! [`PidRouter`] panics at build time if two modules register the same PID to
34//! **different** workflow names. Set explicit [`DeploymentRoles`] to exclude
35//! conflicting registrations from modules that don't apply to this instance.
36//!
37//! [`PidRouter`]: crate::pid_router::PidRouter
38
39use std::collections::HashSet;
40
41// ── Marktrolle ────────────────────────────────────────────────────────────────
42
43/// A BDEW market-participant role (Marktrolle).
44///
45/// Declares which roles this `makod` deployment fills within the German energy
46/// market communication (MaKo) ecosystem. A single deployment may hold several
47/// roles simultaneously (see module-level docs).
48///
49/// # Non-exhaustive
50///
51/// New roles may be added as BDEW regulations expand. Match with `_` in
52/// exhaustive arms or use [`DeploymentRoles::contains`] for membership checks.
53#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Marktrolle {
56    /// Netzbetreiber (NB) — distribution/transmission network operator.
57    ///
58    /// Receives the GPKE ANFRAGE — 55001 (Anmeldung / Lieferbeginn) and 55004
59    /// (Abmeldung / Lieferende) — and issues the matching ANTWORT pair:
60    /// 55002 Bestätigung / 55003 Ablehnung, 55005 Bestätigung / 55006 Ablehnung.
61    /// Also runs GPKE Konfiguration (17134/17135 outbound ORDERS, 19001/19002
62    /// inbound ORDRSP). The Kündigung (55016 → 55017/55018) is an LFN↔LFA
63    /// exchange, not an NB ANFRAGE.
64    Nb,
65
66    /// Lieferant (LF) — energy supplier.
67    ///
68    /// Initiates GPKE Lieferbeginn (55001) and Lieferende (55004), and receives
69    /// the ANTWORT from the NB. Registers as inbound-ANTWORT recipient for
70    /// 55002/55003, 55005/55006 and 55017/55018 in the LF-side anmeldung
71    /// workflow.
72    Lf,
73
74    /// grundzuständiger Messstellenbetreiber (gMSB) — incumbent meter operator.
75    ///
76    /// In the WiM MSB-Wechsel (BK6-24-174) receives the Verpflichtungsanfrage/
77    /// Aufforderung (55168, NB→gMSB); also handles WiM Zählerstand/Konfiguration
78    /// (11001–11003, MSCONS/UTILTS). Often the same legal entity as the NB (§41 MsbG).
79    Msb,
80
81    /// nicht-grundzuständiger Messstellenbetreiber (nMSB) — challenger meter operator.
82    ///
83    /// Sends the WiM MSB-Wechsel Anmeldung (55042, MSBN→NB) and Kündigung MSB
84    /// (55039, MSBN→MSBA), plus WiM Geräteübernahme ORDERS (17001, 17009).
85    /// Receives inbound ORDRSP responses 19001/19002 (Bestellbestätigung/Ablehnung)
86    /// and 19015/19016 (Gerätewechselabsicht).
87    Nmsb,
88
89    /// abgebender Messstellenbetreiber (aMSB) — outgoing meter operator.
90    ///
91    /// Receives the Kündigung MSB (55039, from the nMSB) and sends Ende MSB /
92    /// Abmeldung (55051, MSBA→NB). This role is often held by the gMSB after a
93    /// successful nMSB takeover.
94    Amsb,
95
96    /// Bilanzkreisverantwortlicher (BKV) — balance responsible party.
97    ///
98    /// Receives MABIS billing MSCONS (PID 13003 from BIKO: Abrechnungssummenzeitreihe).
99    Bkv,
100
101    /// Übertragungsnetzbetreiber (ÜNB) — transmission system operator.
102    ///
103    /// Issues BG-SZR Kategorie B/C and BK-SZR Kategorie B/C MSCONS (PID 13003).
104    Uenb,
105
106    /// Bilanzkoordinator (BIKO) — balancing coordinator.
107    ///
108    /// Issues Abrechnungssummenzeitreihe MSCONS (PID 13003) to BKV and NB-DZR.
109    Biko,
110
111    /// Energieserviceanbieter (ESA) — energy service provider acting for the
112    /// Anschlussnutzer (PARTIN 37006, "Kommunikationsdaten des ESA Strom").
113    ///
114    /// **Strom only.** An ESA has no Zuordnung to a Marktlokation: its access to
115    /// values rests on the Anschlussnutzer's consent (§49 Abs. 2 Nr. 9 MsbG) and
116    /// a bilateral contract with the MSB, which §34 Abs. 2 S. 2 Nr. 10 MsbG makes
117    /// a mandatory, non-discriminatory Zusatzleistung.
118    ///
119    /// Sends REQOTE 35003 (Werteanfrage), ORDERS 17007 (Bestellung), ORDERS
120    /// 17008 (Abbestellung) and ORDCHG 39002 (Stornierung); receives QUOTES
121    /// 15003, ORDRSP 19011–19014 and IFTSTA 21042, plus the values themselves
122    /// as MSCONS 13027.
123    ///
124    /// 17007 and 17008 are **different** Prüfidentifikatoren: one orders a
125    /// delivery, the other ends a running one, and their answers cite different
126    /// Entscheidungsbäume (`E_0256` vs `E_0254`).
127    ///
128    /// This role is for a deployment that **is** an ESA. An MSB *serving* an ESA
129    /// registers the inbound side under [`Marktrolle::Msb`].
130    Esa,
131
132    /// Gasnetzbetreiber (GNB) — gas network operator (GeLi Gas counterpart of NB).
133    ///
134    /// Receives GeLi Gas Lieferbeginn/Lieferende ANFRAGE messages (44001 ff.)
135    /// and issues the corresponding ANTWORT messages (44003–44006).
136    Gnb,
137
138    /// Lieferant Gas (LFG) — gas supplier (GeLi Gas counterpart of LF).
139    ///
140    /// Initiates GeLi Gas Lieferbeginn/Lieferende (44001/44002) and receives
141    /// the GNB's ANTWORT messages.
142    Lfg,
143
144    /// Lieferant neu (LFN) — the incoming supplier in a Lieferantenwechsel.
145    ///
146    /// Distinct from the generic [`Marktrolle::Lf`] where a process step is
147    /// specific to the *gaining* side of a switch.
148    Lfn,
149
150    /// Lieferant alt (LFA) — the outgoing supplier in a Lieferantenwechsel.
151    ///
152    /// Distinct from the generic [`Marktrolle::Lf`] where a process step is
153    /// specific to the *losing* side of a switch.
154    Lfa,
155
156    /// Ladepunktbetreiber (LPB/CPO) — charge-point operator running a virtual
157    /// Bilanzierungsgebiet under NZR-EMob / Modell 2.
158    ///
159    /// **A deployment role, never a wire role.** The BDEW Rollenmodell defines
160    /// no LPB: „der LPB kommuniziert aus prozessualer Sicht wie die Rolle NB"
161    /// (AWH „Zum Modell 2" V1.3 Kap. 1.4), so every UTILMD and MSCONS it sends
162    /// carries `NAD+MS` as **NB** and the Anwendungsübersicht spells its column
163    /// „NB (LPB)". What this variant separates is *routing*, the way
164    /// [`Marktrolle::Nmsb`]/[`Marktrolle::Amsb`] and
165    /// [`Marktrolle::Lfn`]/[`Marktrolle::Lfa`] do: one wire role, several
166    /// deployment identities.
167    ///
168    /// Without it the shared Prüfidentifikatoren are ambiguous in a deployment
169    /// that is both a VNB and an LPB. 55238 is *sent* by the LPB and *received*
170    /// by the VNB; 13003 means the Netzzeitreihe to one and the
171    /// Bilanzkreissummenzeitreihe eMob to the other. Registering both under
172    /// [`Marktrolle::Nb`] would route each message to both handlers.
173    ///
174    /// Sends 55238 / 55242 (Modellwechsel), the tägliche BK-SZR eMob and the
175    /// monthly BK-SZR (Kat. A) eMob; receives 55239 / 55243, the
176    /// Netzgangzeitreihe (13018) and the NZR (eMob).
177    Lpb,
178
179    /// Marktgebietsverantwortlicher (MGV) — gas market-area manager.
180    ///
181    /// **Gas only.** Operates the Virtueller Handelspunkt and GaBi Gas
182    /// balancing (THE in Germany). Declares its communication data via
183    /// PARTIN 37011 ("Kommunikationsdaten des MGV Gas").
184    Mgv,
185}
186
187impl Marktrolle {
188    /// The canonical upper-case BDEW role code (e.g. `"NB"`, `"ÜNB"`, `"LFG"`).
189    #[must_use]
190    pub const fn as_code(self) -> &'static str {
191        match self {
192            Self::Nb => "NB",
193            Self::Lf => "LF",
194            Self::Msb => "MSB",
195            Self::Nmsb => "NMSB",
196            Self::Amsb => "AMSB",
197            Self::Bkv => "BKV",
198            Self::Uenb => "ÜNB",
199            Self::Biko => "BIKO",
200            Self::Esa => "ESA",
201            Self::Gnb => "GNB",
202            Self::Lfg => "LFG",
203            Self::Lfn => "LFN",
204            Self::Lfa => "LFA",
205            Self::Lpb => "LPB",
206            Self::Mgv => "MGV",
207        }
208    }
209
210    /// Parse a canonical upper-case BDEW role code back into a [`Marktrolle`].
211    ///
212    /// Round-trips [`as_code`] exactly (including the umlaut in `"ÜNB"`).
213    /// Returns `None` for anything else — callers decide whether an unknown
214    /// code is an error or simply "not one of ours".
215    ///
216    /// [`as_code`]: Marktrolle::as_code
217    #[must_use]
218    pub fn from_code(code: &str) -> Option<Self> {
219        Some(match code {
220            "NB" => Self::Nb,
221            "LF" => Self::Lf,
222            "MSB" => Self::Msb,
223            "NMSB" => Self::Nmsb,
224            "AMSB" => Self::Amsb,
225            "BKV" => Self::Bkv,
226            "ÜNB" => Self::Uenb,
227            "BIKO" => Self::Biko,
228            "ESA" => Self::Esa,
229            "GNB" => Self::Gnb,
230            "LFG" => Self::Lfg,
231            "LFN" => Self::Lfn,
232            "LFA" => Self::Lfa,
233            "LPB" => Self::Lpb,
234            "MGV" => Self::Mgv,
235            _ => return None,
236        })
237    }
238
239    /// Map a PARTIN Prüfidentifikator to the sender's [`Marktrolle`].
240    ///
241    /// PARTIN (PIDs 37000–37014) distributes market-participant communication
242    /// data; the PID identifies the sender's role:
243    ///
244    /// | PID | Sender | `Marktrolle` |
245    /// |---|---|---|
246    /// | 37000 | LF Strom | [`Lf`](Self::Lf) |
247    /// | 37001 | NB Strom | [`Nb`](Self::Nb) |
248    /// | 37002 | MSB Strom | [`Msb`](Self::Msb) |
249    /// | 37003 | BKV Strom | [`Bkv`](Self::Bkv) |
250    /// | 37004 | BIKO Strom | [`Biko`](Self::Biko) |
251    /// | 37005 | ÜNB Strom | [`Uenb`](Self::Uenb) |
252    /// | 37006 | ESA Strom | [`Esa`](Self::Esa) |
253    /// | 37008 | LF Gas | [`Lfg`](Self::Lfg) |
254    /// | 37009 | NB Gas | [`Gnb`](Self::Gnb) |
255    /// | 37010 | MSB Gas | [`Msb`](Self::Msb) |
256    /// | 37011 | MGV Gas | [`Mgv`](Self::Mgv) |
257    /// | 37012 | NB Gas (spartenübergreifend) | [`Gnb`](Self::Gnb) |
258    /// | 37013 | MSB Gas (spartenübergreifend) | [`Msb`](Self::Msb) |
259    /// | 37014 | MSB Strom (spartenübergreifend) | [`Msb`](Self::Msb) |
260    ///
261    /// Returns `None` for unrecognised codes (37007 is a gap in the AHB).
262    #[must_use]
263    pub fn from_partin_pid(pid: u32) -> Option<Self> {
264        match pid {
265            37000 => Some(Self::Lf),
266            37001 => Some(Self::Nb),
267            37002 | 37010 | 37013 | 37014 => Some(Self::Msb),
268            37003 => Some(Self::Bkv),
269            37004 => Some(Self::Biko),
270            37005 => Some(Self::Uenb),
271            37006 => Some(Self::Esa),
272            37008 => Some(Self::Lfg),
273            37009 | 37012 => Some(Self::Gnb),
274            37011 => Some(Self::Mgv),
275            _ => None,
276        }
277    }
278}
279
280// Serde representation: the canonical BDEW role code (`"NB"`, `"ÜNB"`, `"LFG"`, …).
281// Used verbatim in persisted partner records and API payloads, so the wire
282// format matches EDIFACT/BO4E role codes exactly.
283impl serde::Serialize for Marktrolle {
284    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
285        serializer.serialize_str(self.as_code())
286    }
287}
288
289impl<'de> serde::Deserialize<'de> for Marktrolle {
290    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
291        let code = String::deserialize(deserializer)?;
292        Self::from_code(&code)
293            .ok_or_else(|| serde::de::Error::custom(format!("unknown Marktrolle code {code:?}")))
294    }
295}
296
297impl std::fmt::Display for Marktrolle {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        f.write_str(self.as_code())
300    }
301}
302
303// ── DeploymentRoles ───────────────────────────────────────────────────────────
304
305/// The set of [`Marktrolle`]s this `makod` deployment fills.
306///
307/// Used by [`EngineModule::register_pids_with_roles`] to conditionally register
308/// PID routes based on which roles are active. Modules check
309/// `roles.contains(Marktrolle::Nb)` before registering role-specific PIDs.
310///
311/// # Constructors
312///
313/// - [`DeploymentRoles::all()`] — registers everything regardless of role
314///   (useful for development and single-role deployments, default).
315/// - [`DeploymentRoles::from_roles`] — explicit set for multi-role conflict resolution.
316/// - Convenience methods: [`nb()`], [`lf()`], [`msb()`], [`nmsb()`] etc.
317///
318/// # Conflict guard
319///
320/// When two modules both register the same PID to **different** workflow names,
321/// `EngineBuilder::build` will detect the conflict and panic. Set exclusive roles
322/// to ensure only one workflow is registered per shared PID:
323///
324/// ```rust,ignore
325/// // NB deployment: GPKE registers 19001/19002 → gpke-konfiguration
326/// // nMSB deployment: WiM registers 19001/19002 → wim-geraeteubernahme
327/// // Combined (conflict!): set roles to prevent double-registration:
328/// use mako_engine::marktrolle::{DeploymentRoles, Marktrolle};
329///
330/// let roles = DeploymentRoles::from_roles([Marktrolle::Nb]);
331/// // Now only GPKE registers 19001/19002; WiM skips its nMSB-conditional block.
332/// ```
333///
334/// [`EngineModule::register_pids_with_roles`]: crate::builder::EngineModule::register_pids_with_roles
335/// [`nb()`]: DeploymentRoles::nb
336/// [`lf()`]: DeploymentRoles::lf
337/// [`msb()`]: DeploymentRoles::msb
338/// [`nmsb()`]: DeploymentRoles::nmsb
339#[derive(Debug, Clone)]
340pub struct DeploymentRoles {
341    /// When `true`, `contains()` returns `true` for every role (matches all).
342    all: bool,
343    roles: HashSet<Marktrolle>,
344}
345
346impl Default for DeploymentRoles {
347    /// Defaults to `all` — every role is considered active.
348    ///
349    /// This preserves backward-compatible behavior (all PIDs registered) for
350    /// deployments that have not yet configured explicit roles. Set explicit
351    /// roles via [`DeploymentRoles::from_roles`] for multi-role conflict safety.
352    fn default() -> Self {
353        Self::all()
354    }
355}
356
357impl DeploymentRoles {
358    /// All roles active — `contains` always returns `true`.
359    ///
360    /// The default for `EngineBuilder`. Modules register all their PIDs
361    /// unconditionally, identical to the pre-role-aware behavior.
362    ///
363    /// **Warning:** if two modules register the same PID to different workflows
364    /// and `all()` is active, the conflict guard in `PidRouter` will panic at
365    /// build time. Use [`from_roles`] to specify exactly which roles apply.
366    ///
367    /// [`from_roles`]: DeploymentRoles::from_roles
368    #[must_use]
369    pub fn all() -> Self {
370        Self {
371            all: true,
372            roles: HashSet::new(),
373        }
374    }
375
376    /// Construct from an explicit set of active roles.
377    ///
378    /// Only modules whose role-conditional PID blocks include at least one of
379    /// these roles will register those PIDs. All non-role-conditional PID blocks
380    /// (i.e., those that don't call `roles.contains(...)`) are always registered.
381    #[must_use]
382    pub fn from_roles(roles: impl IntoIterator<Item = Marktrolle>) -> Self {
383        Self {
384            all: false,
385            roles: roles.into_iter().collect(),
386        }
387    }
388
389    /// Return `true` when `role` is active.
390    ///
391    /// Always returns `true` for [`DeploymentRoles::all()`].
392    #[must_use]
393    pub fn contains(&self, role: Marktrolle) -> bool {
394        self.all || self.roles.contains(&role)
395    }
396
397    /// Return `true` when this is the [`all()`] sentinel (no explicit role list).
398    ///
399    /// [`all()`]: DeploymentRoles::all
400    #[must_use]
401    pub fn is_all(&self) -> bool {
402        self.all
403    }
404
405    // ── Convenience constructors ──────────────────────────────────────────────
406
407    /// NB-only deployment (most common for grid operators).
408    #[must_use]
409    pub fn nb() -> Self {
410        Self::from_roles([Marktrolle::Nb])
411    }
412
413    /// ESA-only deployment (energy service provider side).
414    #[must_use]
415    pub fn esa() -> Self {
416        Self::from_roles([Marktrolle::Esa])
417    }
418
419    /// LF-only deployment (supplier side).
420    #[must_use]
421    pub fn lf() -> Self {
422        Self::from_roles([Marktrolle::Lf])
423    }
424
425    /// gMSB-only deployment (incumbent meter operator).
426    #[must_use]
427    pub fn msb() -> Self {
428        Self::from_roles([Marktrolle::Msb])
429    }
430
431    /// nMSB-only deployment (challenger meter operator).
432    #[must_use]
433    pub fn nmsb() -> Self {
434        Self::from_roles([Marktrolle::Nmsb])
435    }
436
437    /// NB + gMSB (most common municipal utility / Stadtwerke combination).
438    #[must_use]
439    pub fn nb_msb() -> Self {
440        Self::from_roles([Marktrolle::Nb, Marktrolle::Msb])
441    }
442
443    /// NB + BKV (grid operator that also manages its own balance group).
444    #[must_use]
445    pub fn nb_bkv() -> Self {
446        Self::from_roles([Marktrolle::Nb, Marktrolle::Bkv])
447    }
448
449    /// Add a role to an existing set, returning a new `DeploymentRoles`.
450    #[must_use]
451    pub fn with(mut self, role: Marktrolle) -> Self {
452        if !self.all {
453            self.roles.insert(role);
454        }
455        self
456    }
457}
458
459impl FromIterator<Marktrolle> for DeploymentRoles {
460    fn from_iter<T: IntoIterator<Item = Marktrolle>>(iter: T) -> Self {
461        Self::from_roles(iter)
462    }
463}
464
465// ── Command licensing ─────────────────────────────────────────────────────────
466
467/// Why [`resolve_role`] rejected a command submission.
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum LicensingError {
470    /// The command permits several roles and the caller asserted none —
471    /// the engine cannot infer which hat the caller is wearing.
472    MarktrolleRequired,
473    /// The asserted role is not in the command's permitted set.
474    RoleNotPermitted,
475    /// The effective role is not among the deployment's configured roles.
476    RoleNotConfigured,
477}
478
479impl std::fmt::Display for LicensingError {
480    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
481        match self {
482            Self::MarktrolleRequired => {
483                f.write_str("multi-role command requires an asserted Marktrolle")
484            }
485            Self::RoleNotPermitted => {
486                f.write_str("asserted Marktrolle is not permitted for this command")
487            }
488            Self::RoleNotConfigured => {
489                f.write_str("deployment is not configured for the required Marktrolle")
490            }
491        }
492    }
493}
494
495impl std::error::Error for LicensingError {}
496
497/// Resolve and validate the effective [`Marktrolle`] for a command submission.
498///
499/// Pure licensing policy — no registry lookup, no I/O:
500///
501/// - **Single-role commands** (`permitted.len() == 1`): the role is inferred
502///   from the permitted set; any `asserted` role is deliberately **ignored**
503///   so ERP connectors that always send a fixed role are not rejected.
504/// - **Multi-role commands** (`permitted.len() != 1`): `asserted` must be
505///   `Some` ([`LicensingError::MarktrolleRequired`]) and must be a member of
506///   `permitted` ([`LicensingError::RoleNotPermitted`]).
507///
508/// The effective role is then cross-checked against the deployment
509/// configuration: [`DeploymentRoles::all`] admits every role; an explicit
510/// (possibly empty) role set admits only its members
511/// ([`LicensingError::RoleNotConfigured`]).
512///
513/// # Errors
514///
515/// See [`LicensingError`] for the three rejection reasons.
516pub fn resolve_role(
517    permitted: &[Marktrolle],
518    asserted: Option<Marktrolle>,
519    configured: &DeploymentRoles,
520) -> Result<Marktrolle, LicensingError> {
521    let effective = if permitted.len() == 1 {
522        // Single-role command — fully implied; asserted role is ignored.
523        permitted[0]
524    } else {
525        let r = asserted.ok_or(LicensingError::MarktrolleRequired)?;
526        if !permitted.contains(&r) {
527            return Err(LicensingError::RoleNotPermitted);
528        }
529        r
530    };
531
532    if !configured.contains(effective) {
533        return Err(LicensingError::RoleNotConfigured);
534    }
535
536    Ok(effective)
537}
538
539#[cfg(test)]
540mod licensing_tests {
541    use super::*;
542
543    #[test]
544    fn code_round_trip_for_every_role() {
545        for role in [
546            Marktrolle::Nb,
547            Marktrolle::Lf,
548            Marktrolle::Msb,
549            Marktrolle::Nmsb,
550            Marktrolle::Amsb,
551            Marktrolle::Bkv,
552            Marktrolle::Uenb,
553            Marktrolle::Biko,
554            Marktrolle::Esa,
555            Marktrolle::Gnb,
556            Marktrolle::Lfg,
557            Marktrolle::Lfn,
558            Marktrolle::Lfa,
559            Marktrolle::Mgv,
560        ] {
561            assert_eq!(Marktrolle::from_code(role.as_code()), Some(role));
562        }
563        assert_eq!(Marktrolle::from_code("ÜNB"), Some(Marktrolle::Uenb));
564        assert_eq!(
565            Marktrolle::from_code("nb"),
566            None,
567            "codes are case-sensitive"
568        );
569        assert_eq!(Marktrolle::from_code(""), None);
570    }
571
572    #[test]
573    fn serde_round_trips_as_bdew_code() {
574        for role in [Marktrolle::Nb, Marktrolle::Uenb, Marktrolle::Lfg] {
575            let json = serde_json::to_string(&role).unwrap();
576            assert_eq!(json, format!("\"{}\"", role.as_code()));
577            let back: Marktrolle = serde_json::from_str(&json).unwrap();
578            assert_eq!(back, role);
579        }
580        assert!(serde_json::from_str::<Marktrolle>("\"LfStrom\"").is_err());
581    }
582
583    #[test]
584    fn from_partin_pid_covers_all_partin_pids() {
585        for pid in [
586            37000u32, 37001, 37002, 37003, 37004, 37005, 37006, 37008, 37009, 37010, 37011, 37012,
587            37013, 37014,
588        ] {
589            assert!(
590                Marktrolle::from_partin_pid(pid).is_some(),
591                "from_partin_pid({pid}) should return Some"
592            );
593        }
594        assert_eq!(Marktrolle::from_partin_pid(37000), Some(Marktrolle::Lf));
595        assert_eq!(Marktrolle::from_partin_pid(37008), Some(Marktrolle::Lfg));
596        assert_eq!(Marktrolle::from_partin_pid(37009), Some(Marktrolle::Gnb));
597        assert_eq!(Marktrolle::from_partin_pid(37011), Some(Marktrolle::Mgv));
598        assert_eq!(Marktrolle::from_partin_pid(37014), Some(Marktrolle::Msb));
599        // PID 37007 is not in the AHB (gap)
600        assert_eq!(Marktrolle::from_partin_pid(37007), None);
601        assert_eq!(Marktrolle::from_partin_pid(0), None);
602    }
603
604    #[test]
605    fn single_permitted_infers_and_ignores_assertion() {
606        let configured = DeploymentRoles::lf();
607        // No assertion → inferred.
608        assert_eq!(
609            resolve_role(&[Marktrolle::Lf], None, &configured),
610            Ok(Marktrolle::Lf)
611        );
612        // A wrong assertion is ignored, not rejected.
613        assert_eq!(
614            resolve_role(&[Marktrolle::Lf], Some(Marktrolle::Nb), &configured),
615            Ok(Marktrolle::Lf)
616        );
617    }
618
619    #[test]
620    fn multi_permitted_requires_assertion() {
621        let permitted = [Marktrolle::Nb, Marktrolle::Msb];
622        let configured = DeploymentRoles::nb_msb();
623        assert_eq!(
624            resolve_role(&permitted, None, &configured),
625            Err(LicensingError::MarktrolleRequired)
626        );
627        assert_eq!(
628            resolve_role(&permitted, Some(Marktrolle::Msb), &configured),
629            Ok(Marktrolle::Msb)
630        );
631    }
632
633    #[test]
634    fn multi_permitted_rejects_foreign_assertion() {
635        let permitted = [Marktrolle::Nb, Marktrolle::Msb];
636        let configured = DeploymentRoles::lf();
637        assert_eq!(
638            resolve_role(&permitted, Some(Marktrolle::Lf), &configured),
639            Err(LicensingError::RoleNotPermitted)
640        );
641    }
642
643    #[test]
644    fn configured_cross_check_rejects_unconfigured_role() {
645        // Resolves to LF; only NB is configured.
646        assert_eq!(
647            resolve_role(&[Marktrolle::Lf], None, &DeploymentRoles::nb()),
648            Err(LicensingError::RoleNotConfigured)
649        );
650        // Empty explicit set admits nothing.
651        assert_eq!(
652            resolve_role(&[Marktrolle::Lf], None, &DeploymentRoles::from_roles([])),
653            Err(LicensingError::RoleNotConfigured)
654        );
655    }
656
657    #[test]
658    fn deployment_roles_all_admits_every_role() {
659        assert_eq!(
660            resolve_role(&[Marktrolle::Biko], None, &DeploymentRoles::all()),
661            Ok(Marktrolle::Biko)
662        );
663        assert_eq!(
664            resolve_role(
665                &[Marktrolle::Bkv, Marktrolle::Uenb],
666                Some(Marktrolle::Uenb),
667                &DeploymentRoles::all()
668            ),
669            Ok(Marktrolle::Uenb)
670        );
671    }
672}
673
674#[cfg(test)]
675mod esa_role_tests {
676    use super::*;
677
678    /// An ESA-only deployment activates exactly that role.
679    #[test]
680    fn esa_is_a_selectable_deployment_role() {
681        let roles = DeploymentRoles::esa();
682        assert!(roles.contains(Marktrolle::Esa));
683        assert!(!roles.contains(Marktrolle::Msb));
684        assert!(!roles.is_all());
685    }
686
687    /// An integrated deployment can be both: the MSB serves ESAs and the ESA
688    /// arm consumes values. The two register disjoint PID sets.
689    #[test]
690    fn msb_and_esa_can_be_held_together() {
691        let roles = DeploymentRoles::from_roles([Marktrolle::Msb, Marktrolle::Esa]);
692        assert!(roles.contains(Marktrolle::Msb));
693        assert!(roles.contains(Marktrolle::Esa));
694    }
695}