Skip to main content

mako_engine/
pid_router.rs

1//! PID-to-workflow routing table.
2//!
3//! Every inbound EDIFACT message carries a `Prüfidentifikator` (PID) that
4//! identifies the MaKo process family and operation. The `PidRouter` maps
5//! numeric PID values to workflow names, enabling dispatchers to instantiate
6//! the correct [`Workflow`] implementation without ad-hoc `match` chains.
7//!
8//! # Mutability contract — build-time only
9//!
10//! `PidRouter` uses `&mut self` for all registrations. In normal engine usage
11//! the router is populated **once** during `EngineBuilder::build()` and is
12//! subsequently sealed inside `EngineContext` behind a shared `&PidRouter`
13//! reference. There is no runtime mutation path — all PIDs must be registered
14//! before the engine starts serving messages.
15//!
16//! This is intentional: mutation after startup would race with concurrent
17//! dispatch calls and require a `RwLock`. The read-only runtime path is
18//! therefore always lock-free.
19//!
20//! # BDEW PID ranges (incomplete — register all PIDs for your process families)
21//!
22//! | Range | Process family |
23//! |---|---|
24//! | 11001–11099 | WiM Gerätewechsel (UTILMD) |
25//! | 13003        | MABIS Bilanzkreisabrechnung (MSCONS) |
26//! | 13002–13028  | Messwerte Gas/Strom/Redispatch (MSCONS) — fragmented across GaBi Gas, Redispatch, GPKE support |
27//! | 17001–17011 | WiM MSB commissioning (ORDERS) |
28//! | 17101–17135 | WiM Stammdaten / Konfiguration (ORDERS) |
29//! | 31001–31002, 31004–31008 | GPKE Netznutzungsabrechnung / MMM-Rechnung (INVOIC) |
30//! | 31003, 31009 | WiM-Rechnung / MSB-Rechnung (INVOIC) — WiM domain |
31//! | 31010 | Kapazitätsrechnung (INVOIC) — Kapazitätsabrechnung Ausspeisepunkte Gas |
32//! | 31011 | Rechnung sonstige Leistung (INVOIC) — AWH Sperrprozesse Gas |
33//! | 33001–33004  | REMADV Bestätigung/Abweisung — paired with INVOIC workflows |
34//! | 37000–37006  | PARTIN Kommunikationsdaten Strom (GPKE Teil 4) |
35//! | 37008–37014  | PARTIN Kommunikationsdaten Gas (GeLi Gas 2.0) |
36//! | 39000–39001 | ORDCHG Stornierung Sperr-/Entsperrauftrag (AWH Sperrprozesse Gas) |
37//! | 39002 | ORDCHG Stornierung Bestellung (WiM Strom Teil 2) |
38//! | 44001–44018 | GeLi Gas Lieferantenwechsel (UTILMD G) |
39//! | 55001–55018 | GPKE Lieferantenwechsel / Kündigung (UTILMD Strom) |
40//! | 55555 | GPKE Teil 4 — Anfrage Daten der individuellen Bestellung (UTILMD Strom) |
41//!
42//! # Usage
43//!
44//! ```rust
45//! use mako_engine::pid_router::PidRouter;
46//!
47//! let mut router = PidRouter::new();
48//! router.register(55001, "GpkeSupplierChange");
49//! router.register(55002, "GpkeSupplierChange"); // Same workflow, different step
50//!
51//! assert_eq!(router.route(55001), Some("GpkeSupplierChange"));
52//! assert_eq!(router.route(99999), None);
53//! assert_eq!(router.len(), 2);
54//! ```
55//!
56//! [`Workflow`]: crate::workflow::Workflow
57
58use std::collections::HashMap;
59
60use crate::types::Sparte;
61
62// ── PidRouter ─────────────────────────────────────────────────────────────────
63
64/// A static mapping from `Prüfidentifikator` (PID) values to workflow names.
65///
66/// Register all PIDs your platform handles before starting the engine. At
67/// runtime, call [`route`] to look up the workflow name for an inbound PID.
68///
69/// The workflow name matches [`WorkflowId::name`] — use it to select the
70/// correct `Workflow` implementation in your message dispatcher.
71///
72/// # Mutability contract
73///
74/// `PidRouter` exposes a `&mut self` API for registrations (`register`). In
75/// the engine this mutability is exercised **only** during
76/// [`EngineBuilder::build`] — after that the router is owned by
77/// [`EngineContext`] and only shared references are available at runtime.
78/// There is no way to mutate the router from an async dispatch handler.
79///
80/// Duplicate registrations silently replace the previous mapping; the last
81/// call wins. Use `cargo xtask validate-pruefids` to detect PID conflicts
82/// between modules before they reach production.
83///
84/// # Building a complete router
85///
86/// In your `main` or integration module, register every PID that the platform
87/// must handle. PIDs not registered will return `None` from [`route`], causing
88/// the dispatcher to dead-letter the message cleanly.
89///
90/// ```rust
91/// use mako_engine::pid_router::PidRouter;
92///
93/// fn build_router() -> PidRouter {
94///     let mut r = PidRouter::new();
95///     // GPKE Lieferantenwechsel (BK6-22-024) — UTILMD
96///     r.register(55001, "GpkeSupplierChange");
97///     r.register(55002, "GpkeSupplierChange");
98///     r.register(55003, "GpkeSupplierChange");
99///     r.register(55004, "GpkeSupplierChange");
100///     r
101/// }
102/// ```
103///
104/// [`route`]: PidRouter::route
105/// [`WorkflowId::name`]: crate::version::WorkflowId::name
106/// [`EngineBuilder::build`]: crate::builder::EngineBuilder::build
107/// [`EngineContext`]: crate::builder::EngineContext
108#[derive(Debug, Default, Clone)]
109pub struct PidRouter {
110    table: HashMap<u32, Box<str>>,
111    /// Commodity-qualified routing table: `(pid, Sparte) → workflow_name`.
112    ///
113    /// Checked first by [`route_with_sparte`]; falls back to the unambiguous
114    /// [`table`] when no commodity-specific entry exists.
115    ///
116    /// Use this for PIDs shared between Strom and Gas process families where
117    /// the APERAK Frist differs:
118    ///
119    /// | PID   | Strom workflow  | Gas workflow      |
120    /// |-------|-----------------|-------------------|
121    /// | 23001 | `wim-insrpt`    | `wim-gas-insrpt`  |
122    /// | 23003 | `wim-insrpt`    | `wim-gas-insrpt`  |
123    /// | 23004 | `wim-insrpt`    | `wim-gas-insrpt`  |
124    /// | 23008 | `wim-insrpt`    | `wim-gas-insrpt`  |
125    ///
126    /// [`route_with_sparte`]: PidRouter::route_with_sparte
127    /// [`table`]: PidRouter::table
128    commodity_table: HashMap<(u32, Sparte), Box<str>>,
129    /// Tracks which module registered each PID for conflict detection.
130    ///
131    /// Populated by [`register_with_module`]; used to produce actionable
132    /// panic messages when two modules register the same PID to different workflows.
133    ///
134    /// [`register_with_module`]: PidRouter::register_with_module
135    registered_by: HashMap<u32, Box<str>>,
136}
137
138impl PidRouter {
139    /// Create an empty router.
140    #[must_use]
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    /// Register `pid` as routing to `workflow_name`.
146    ///
147    /// If `pid` was already registered, the previous mapping is silently
148    /// replaced. Call this only at build time (via [`EngineModule::register_pids`]);
149    /// the method is `&mut self` to prevent accidental runtime mutation once the
150    /// router is sealed inside [`EngineContext`].
151    ///
152    /// Accepts any string — `&'static str`, `String`, or `Box<str>`.
153    ///
154    /// For conflict-detected registration (preferred in multi-module builds),
155    /// use [`register_with_module`] instead.
156    ///
157    /// [`EngineModule::register_pids`]: crate::builder::EngineModule::register_pids
158    /// [`EngineContext`]: crate::builder::EngineContext
159    /// [`register_with_module`]: PidRouter::register_with_module
160    pub fn register(&mut self, pid: u32, workflow_name: impl Into<Box<str>>) {
161        let wf = workflow_name.into();
162        self.table.insert(pid, wf);
163    }
164
165    /// Register `pid` → `workflow_name` with module-attribution conflict detection.
166    ///
167    /// # Panics
168    ///
169    /// Panics at **build time** (before the engine starts) if `pid` is already
170    /// registered to a *different* workflow name by a *different* module. Two
171    /// modules registering the same PID to the **same** workflow are silently
172    /// accepted (idempotent).
173    ///
174    /// Use [`DeploymentRoles`] to prevent two modules from registering the same
175    /// PID when only one role is active:
176    ///
177    /// ```rust,ignore
178    /// // Both GPKE (NB role) and WiM (nMSB role) register 19001 → different workflows.
179    /// // Set explicit roles so only one module's conditional block fires:
180    /// use mako_engine::marktrolle::{DeploymentRoles, Marktrolle};
181    /// let roles = DeploymentRoles::from_roles([Marktrolle::Nb]);
182    /// // Now only GPKE registers 19001 → "gpke-konfiguration".
183    /// ```
184    ///
185    /// [`DeploymentRoles`]: crate::marktrolle::DeploymentRoles
186    pub fn register_with_module(
187        &mut self,
188        pid: u32,
189        workflow_name: impl Into<Box<str>>,
190        module: &str,
191    ) {
192        let wf = workflow_name.into();
193        if let Some(existing_wf) = self.table.get(&pid)
194            && *existing_wf != wf
195        {
196            let existing_mod = self
197                .registered_by
198                .get(&pid)
199                .map_or("<unknown>", Box::as_ref);
200            panic!(
201                "PID {pid} routing conflict:\n  \
202                     module '{module}' tried to register PID {pid} → '{wf}'\n  \
203                     but it was already registered → '{existing_wf}' by module '{existing_mod}'\n  \
204                     Hint: use DeploymentRoles to prevent conflicting modules from \
205                     both registering shared PIDs (e.g. 19001/19002 are claimed by \
206                     gpke-konfiguration for NB role and wim-geraeteubernahme for nMSB role).\n  \
207                     Set EngineBuilder::with_deployment_roles(DeploymentRoles::nb()) to keep \
208                     only the NB-role registration."
209            );
210        }
211        self.table.insert(pid, wf);
212        self.registered_by.insert(pid, module.into());
213    }
214
215    /// Register `pid` → `workflow_name` for a specific commodity ([`Sparte`]).
216    ///
217    /// Use this for PIDs that map to **different workflows depending on whether
218    /// the message concerns electricity (Strom) or gas (Gas)**. At runtime call
219    /// [`route_with_sparte`] to prefer the commodity-specific entry over the
220    /// unambiguous fallback registered via [`register`].
221    ///
222    /// # INSRPT shared PIDs (23001/23003/23004/23008)
223    ///
224    /// These PIDs appear in both WiM Strom (5 WT) and WiM Gas (10 WT) AHBs:
225    ///
226    /// ```rust,ignore
227    /// // In WimModule (Strom):
228    /// router.register_with_sparte(23001, Sparte::Strom, "wim-insrpt");
229    ///
230    /// // In WimGasModule (Gas):
231    /// router.register_with_sparte(23001, Sparte::Gas, "wim-gas-insrpt");
232    /// ```
233    ///
234    /// [`route_with_sparte`]: PidRouter::route_with_sparte
235    /// [`register`]: PidRouter::register
236    pub fn register_with_sparte(
237        &mut self,
238        pid: u32,
239        sparte: Sparte,
240        workflow_name: impl Into<Box<str>>,
241    ) {
242        self.commodity_table
243            .insert((pid, sparte), workflow_name.into());
244    }
245
246    /// Look up the workflow name for `pid`, preferring the commodity-qualified
247    /// entry for `sparte` over the unambiguous fallback.
248    ///
249    /// Resolution order:
250    /// 1. `commodity_table[(pid, sparte)]` — registered via [`register_with_sparte`]
251    /// 2. `table[pid]` — registered via [`register`] (unambiguous fallback)
252    ///
253    /// Returns `None` when neither table has an entry for `pid`.
254    ///
255    /// # INSRPT routing example
256    ///
257    /// ```rust
258    /// use mako_engine::pid_router::PidRouter;
259    /// use mako_engine::types::Sparte;
260    ///
261    /// let mut r = PidRouter::new();
262    /// r.register(23001, "wim-insrpt");                            // Strom fallback
263    /// r.register_with_sparte(23001, Sparte::Strom, "wim-insrpt");
264    /// r.register_with_sparte(23001, Sparte::Gas,   "wim-gas-insrpt");
265    ///
266    /// assert_eq!(r.route_with_sparte(23001, Sparte::Strom), Some("wim-insrpt"));
267    /// assert_eq!(r.route_with_sparte(23001, Sparte::Gas),   Some("wim-gas-insrpt"));
268    /// assert_eq!(r.route(23001),                             Some("wim-insrpt"));
269    /// ```
270    ///
271    /// [`register_with_sparte`]: PidRouter::register_with_sparte
272    /// [`register`]: PidRouter::register
273    #[must_use]
274    pub fn route_with_sparte(&self, pid: u32, sparte: Sparte) -> Option<&str> {
275        self.commodity_table
276            .get(&(pid, sparte))
277            .or_else(|| self.table.get(&pid))
278            .map(Box::as_ref)
279    }
280
281    /// Look up the workflow name for `pid`.
282    ///
283    /// Returns `None` when `pid` has not been registered. The caller should
284    /// dead-letter the message and return an appropriate error to the sender
285    /// rather than panicking.
286    #[must_use]
287    pub fn route(&self, pid: u32) -> Option<&str> {
288        self.table.get(&pid).map(Box::as_ref)
289    }
290
291    /// Return an iterator over all registered PID values.
292    ///
293    /// Useful for validation (e.g. comparing against PIDs declared in
294    /// AHB profile JSON files to detect missing workflow implementations).
295    pub fn registered_pids(&self) -> impl Iterator<Item = u32> + '_ {
296        self.table.keys().copied()
297    }
298
299    /// Return the number of registered PID mappings.
300    #[must_use]
301    pub fn len(&self) -> usize {
302        self.table.len()
303    }
304
305    /// Return `true` when no PIDs have been registered.
306    #[must_use]
307    pub fn is_empty(&self) -> bool {
308        self.table.is_empty()
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn route_registered_pid() {
318        let mut r = PidRouter::new();
319        r.register(55001, "GpkeSupplierChange");
320        assert_eq!(r.route(55001), Some("GpkeSupplierChange"));
321    }
322    #[test]
323    fn route_unregistered_pid_returns_none() {
324        let r = PidRouter::new();
325        assert_eq!(r.route(55001), None);
326    }
327
328    #[test]
329    fn register_overwrites_previous_mapping() {
330        let mut r = PidRouter::new();
331        r.register(55001, "OldWorkflow");
332        r.register(55001, "NewWorkflow");
333        assert_eq!(r.route(55001), Some("NewWorkflow"));
334        assert_eq!(r.len(), 1);
335    }
336
337    #[test]
338    fn registered_pids_covers_all_entries() {
339        let mut r = PidRouter::new();
340        r.register(55001, "A");
341        r.register(55002, "B");
342        r.register(11001, "C");
343
344        let mut pids: Vec<u32> = r.registered_pids().collect();
345        pids.sort_unstable();
346        assert_eq!(pids, [11001, 55001, 55002]);
347    }
348
349    #[test]
350    fn multiple_pids_same_workflow() {
351        let mut r = PidRouter::new();
352        r.register(55001, "GpkeSupplierChange");
353        r.register(55002, "GpkeSupplierChange");
354        r.register(55003, "GpkeSupplierChange");
355
356        assert_eq!(r.len(), 3);
357        for pid in [55001, 55002, 55003] {
358            assert_eq!(r.route(pid), Some("GpkeSupplierChange"));
359        }
360    }
361
362    #[test]
363    fn is_empty_and_len() {
364        let mut r = PidRouter::new();
365        assert!(r.is_empty());
366        r.register(55001, "W");
367        assert!(!r.is_empty());
368        assert_eq!(r.len(), 1);
369    }
370
371    // ── Commodity-aware routing ───────────────────────────────────────────────
372
373    #[test]
374    fn route_with_sparte_prefers_commodity_entry() {
375        use crate::types::Sparte;
376        let mut r = PidRouter::new();
377        r.register(23001, "wim-insrpt"); // unambiguous fallback
378        r.register_with_sparte(23001, Sparte::Strom, "wim-insrpt");
379        r.register_with_sparte(23001, Sparte::Gas, "wim-gas-insrpt");
380
381        assert_eq!(
382            r.route_with_sparte(23001, Sparte::Strom),
383            Some("wim-insrpt")
384        );
385        assert_eq!(
386            r.route_with_sparte(23001, Sparte::Gas),
387            Some("wim-gas-insrpt")
388        );
389        // Unambiguous route() is unaffected by commodity table.
390        assert_eq!(r.route(23001), Some("wim-insrpt"));
391    }
392
393    #[test]
394    fn route_with_sparte_falls_back_to_unambiguous() {
395        use crate::types::Sparte;
396        let mut r = PidRouter::new();
397        // No commodity entry — only unambiguous.
398        r.register(55001, "GpkeSupplierChange");
399
400        assert_eq!(
401            r.route_with_sparte(55001, Sparte::Strom),
402            Some("GpkeSupplierChange")
403        );
404        assert_eq!(
405            r.route_with_sparte(55001, Sparte::Gas),
406            Some("GpkeSupplierChange")
407        );
408    }
409
410    #[test]
411    fn route_with_sparte_returns_none_for_unregistered() {
412        use crate::types::Sparte;
413        let r = PidRouter::new();
414        assert_eq!(r.route_with_sparte(23001, Sparte::Strom), None);
415        assert_eq!(r.route_with_sparte(23001, Sparte::Gas), None);
416    }
417
418    #[test]
419    fn route_with_sparte_gas_only_deployment() {
420        use crate::types::Sparte;
421        // Gas-standalone: only Gas entries; no WimModule Strom fallback.
422        let mut r = PidRouter::new();
423        r.register(23001, "wim-gas-insrpt"); // unambiguous (Gas-standalone)
424        r.register_with_sparte(23001, Sparte::Gas, "wim-gas-insrpt");
425
426        assert_eq!(
427            r.route_with_sparte(23001, Sparte::Gas),
428            Some("wim-gas-insrpt")
429        );
430        // Strom falls back to unambiguous (no Strom-specific entry exists).
431        assert_eq!(
432            r.route_with_sparte(23001, Sparte::Strom),
433            Some("wim-gas-insrpt")
434        );
435    }
436
437    #[test]
438    fn route_with_sparte_combined_deployment_all_shared_insrpt_pids() {
439        use crate::types::Sparte;
440        let mut r = PidRouter::new();
441        // WimModule registers shared PIDs as Strom:
442        for pid in [23001_u32, 23003, 23004, 23008] {
443            r.register(pid, "wim-insrpt");
444            r.register_with_sparte(pid, Sparte::Strom, "wim-insrpt");
445        }
446        // WimGasModule registers shared PIDs as Gas:
447        for pid in [23001_u32, 23003, 23004, 23008] {
448            r.register_with_sparte(pid, Sparte::Gas, "wim-gas-insrpt");
449        }
450
451        for pid in [23001_u32, 23003, 23004, 23008] {
452            assert_eq!(
453                r.route_with_sparte(pid, Sparte::Strom),
454                Some("wim-insrpt"),
455                "PID {pid} Strom should route to wim-insrpt"
456            );
457            assert_eq!(
458                r.route_with_sparte(pid, Sparte::Gas),
459                Some("wim-gas-insrpt"),
460                "PID {pid} Gas should route to wim-gas-insrpt"
461            );
462        }
463    }
464}