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 an iterator over all commodity-qualified routing entries.
300    ///
301    /// Yields `(pid, sparte, workflow_name)` tuples.
302    ///
303    /// Used by [`EngineBuilder`] to copy sparte-qualified entries from a scratch
304    /// router into the real [`PidRouter`] without conflict detection (commodity
305    /// entries use distinct `(pid, sparte)` keys and never conflict across modules).
306    ///
307    /// [`EngineBuilder`]: crate::builder::EngineBuilder
308    pub fn registered_commodity_entries(&self) -> impl Iterator<Item = (u32, Sparte, &str)> + '_ {
309        self.commodity_table
310            .iter()
311            .map(|((pid, sparte), wf)| (*pid, *sparte, wf.as_ref()))
312    }
313
314    /// Return a sorted, deduplicated list of all workflow names registered in
315    /// this router (across both the unambiguous table and commodity-qualified
316    /// table).
317    ///
318    /// Used at startup by `validate_dispatch_completeness` to verify that every
319    /// workflow name reachable via PID routing has a matching arm in the
320    /// `EdifactIngestDispatcher`.
321    #[must_use]
322    pub fn workflow_names(&self) -> Vec<&str> {
323        let mut names: Vec<&str> = self
324            .table
325            .values()
326            .map(Box::as_ref)
327            .chain(self.commodity_table.values().map(Box::as_ref))
328            .collect();
329        names.sort_unstable();
330        names.dedup();
331        names
332    }
333
334    /// Return the number of registered PID mappings.
335    #[must_use]
336    pub fn len(&self) -> usize {
337        self.table.len()
338    }
339
340    /// Return `true` when no PIDs have been registered.
341    #[must_use]
342    pub fn is_empty(&self) -> bool {
343        self.table.is_empty()
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn route_registered_pid() {
353        let mut r = PidRouter::new();
354        r.register(55001, "GpkeSupplierChange");
355        assert_eq!(r.route(55001), Some("GpkeSupplierChange"));
356    }
357    #[test]
358    fn route_unregistered_pid_returns_none() {
359        let r = PidRouter::new();
360        assert_eq!(r.route(55001), None);
361    }
362
363    #[test]
364    fn register_overwrites_previous_mapping() {
365        let mut r = PidRouter::new();
366        r.register(55001, "OldWorkflow");
367        r.register(55001, "NewWorkflow");
368        assert_eq!(r.route(55001), Some("NewWorkflow"));
369        assert_eq!(r.len(), 1);
370    }
371
372    #[test]
373    fn registered_pids_covers_all_entries() {
374        let mut r = PidRouter::new();
375        r.register(55001, "A");
376        r.register(55002, "B");
377        r.register(11001, "C");
378
379        let mut pids: Vec<u32> = r.registered_pids().collect();
380        pids.sort_unstable();
381        assert_eq!(pids, [11001, 55001, 55002]);
382    }
383
384    #[test]
385    fn multiple_pids_same_workflow() {
386        let mut r = PidRouter::new();
387        r.register(55001, "GpkeSupplierChange");
388        r.register(55002, "GpkeSupplierChange");
389        r.register(55003, "GpkeSupplierChange");
390
391        assert_eq!(r.len(), 3);
392        for pid in [55001, 55002, 55003] {
393            assert_eq!(r.route(pid), Some("GpkeSupplierChange"));
394        }
395    }
396
397    #[test]
398    fn is_empty_and_len() {
399        let mut r = PidRouter::new();
400        assert!(r.is_empty());
401        r.register(55001, "W");
402        assert!(!r.is_empty());
403        assert_eq!(r.len(), 1);
404    }
405
406    // ── Commodity-aware routing ───────────────────────────────────────────────
407
408    #[test]
409    fn route_with_sparte_prefers_commodity_entry() {
410        use crate::types::Sparte;
411        let mut r = PidRouter::new();
412        r.register(23001, "wim-insrpt"); // unambiguous fallback
413        r.register_with_sparte(23001, Sparte::Strom, "wim-insrpt");
414        r.register_with_sparte(23001, Sparte::Gas, "wim-gas-insrpt");
415
416        assert_eq!(
417            r.route_with_sparte(23001, Sparte::Strom),
418            Some("wim-insrpt")
419        );
420        assert_eq!(
421            r.route_with_sparte(23001, Sparte::Gas),
422            Some("wim-gas-insrpt")
423        );
424        // Unambiguous route() is unaffected by commodity table.
425        assert_eq!(r.route(23001), Some("wim-insrpt"));
426    }
427
428    #[test]
429    fn route_with_sparte_falls_back_to_unambiguous() {
430        use crate::types::Sparte;
431        let mut r = PidRouter::new();
432        // No commodity entry — only unambiguous.
433        r.register(55001, "GpkeSupplierChange");
434
435        assert_eq!(
436            r.route_with_sparte(55001, Sparte::Strom),
437            Some("GpkeSupplierChange")
438        );
439        assert_eq!(
440            r.route_with_sparte(55001, Sparte::Gas),
441            Some("GpkeSupplierChange")
442        );
443    }
444
445    #[test]
446    fn route_with_sparte_returns_none_for_unregistered() {
447        use crate::types::Sparte;
448        let r = PidRouter::new();
449        assert_eq!(r.route_with_sparte(23001, Sparte::Strom), None);
450        assert_eq!(r.route_with_sparte(23001, Sparte::Gas), None);
451    }
452
453    #[test]
454    fn route_with_sparte_gas_only_deployment() {
455        use crate::types::Sparte;
456        // Gas-standalone: only Gas entries; no WimModule Strom fallback.
457        let mut r = PidRouter::new();
458        r.register(23001, "wim-gas-insrpt"); // unambiguous (Gas-standalone)
459        r.register_with_sparte(23001, Sparte::Gas, "wim-gas-insrpt");
460
461        assert_eq!(
462            r.route_with_sparte(23001, Sparte::Gas),
463            Some("wim-gas-insrpt")
464        );
465        // Strom falls back to unambiguous (no Strom-specific entry exists).
466        assert_eq!(
467            r.route_with_sparte(23001, Sparte::Strom),
468            Some("wim-gas-insrpt")
469        );
470    }
471
472    #[test]
473    fn route_with_sparte_combined_deployment_all_shared_insrpt_pids() {
474        use crate::types::Sparte;
475        let mut r = PidRouter::new();
476        // WimModule registers shared PIDs as Strom:
477        for pid in [23001_u32, 23003, 23004, 23008] {
478            r.register(pid, "wim-insrpt");
479            r.register_with_sparte(pid, Sparte::Strom, "wim-insrpt");
480        }
481        // WimGasModule registers shared PIDs as Gas:
482        for pid in [23001_u32, 23003, 23004, 23008] {
483            r.register_with_sparte(pid, Sparte::Gas, "wim-gas-insrpt");
484        }
485
486        for pid in [23001_u32, 23003, 23004, 23008] {
487            assert_eq!(
488                r.route_with_sparte(pid, Sparte::Strom),
489                Some("wim-insrpt"),
490                "PID {pid} Strom should route to wim-insrpt"
491            );
492            assert_eq!(
493                r.route_with_sparte(pid, Sparte::Gas),
494                Some("wim-gas-insrpt"),
495                "PID {pid} Gas should route to wim-gas-insrpt"
496            );
497        }
498    }
499}