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, 17002, 17009 | WiM Geräteübernahme (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/// [`register`] replaces a duplicate silently — the last call wins — while
81/// [`register_with_module`] panics at build time when two modules claim one PID
82/// for different workflows.
83///
84/// **Most registrations go through [`register`], so within one router the last
85/// module pushed wins a contested PID.** That is why the guards that need a
86/// complete picture build **one router per module** and merge afterwards rather
87/// than sharing one: a PID two families both legitimately carry (COMDIS 29001
88/// is answered by the GPKE, WiM and GaBi-Gas invoice workflows alike) would
89/// otherwise be reported under whichever module the daemon happens to push
90/// last. Where that ambiguity has to be resolved at runtime it is resolved on
91/// the message, not on the table: [`register_with_sparte`] /
92/// [`route_with_sparte`] discriminate by Sparte, and the ingest dispatcher
93/// re-resolves an answer to the family holding the open process it refers to.
94/// Reach for [`register_with_module`] when a PID genuinely belongs to one
95/// family and a second claim would be a bug.
96///
97/// [`register_with_sparte`]: PidRouter::register_with_sparte
98/// [`route_with_sparte`]: PidRouter::route_with_sparte
99///
100/// [`register`]: PidRouter::register
101/// [`register_with_module`]: PidRouter::register_with_module
102///
103/// # Building a complete router
104///
105/// In your `main` or integration module, register every PID that the platform
106/// must handle. PIDs not registered will return `None` from [`route`], causing
107/// the dispatcher to dead-letter the message cleanly.
108///
109/// ```rust
110/// use mako_engine::pid_router::PidRouter;
111///
112/// fn build_router() -> PidRouter {
113///     let mut r = PidRouter::new();
114///     // GPKE Lieferantenwechsel (BK6-22-024) — UTILMD
115///     r.register(55001, "GpkeSupplierChange");
116///     r.register(55002, "GpkeSupplierChange");
117///     r.register(55003, "GpkeSupplierChange");
118///     r.register(55004, "GpkeSupplierChange");
119///     r
120/// }
121/// ```
122///
123/// [`route`]: PidRouter::route
124/// [`WorkflowId::name`]: crate::version::WorkflowId::name
125/// [`EngineBuilder::build`]: crate::builder::EngineBuilder::build
126/// [`EngineContext`]: crate::builder::EngineContext
127#[derive(Debug, Default, Clone)]
128pub struct PidRouter {
129    table: HashMap<u32, Box<str>>,
130    /// Commodity-qualified routing table: `(pid, Sparte) → workflow_name`.
131    ///
132    /// Checked first by [`route_with_sparte`]; falls back to the unambiguous
133    /// [`table`] when no commodity-specific entry exists.
134    ///
135    /// Use this for a Prüfidentifikator that two *different process families*
136    /// share across the Sparten. A PID that only differs in Frist or Codeliste
137    /// between Strom and Gas does not belong here: one workflow takes the
138    /// Sparte as an argument instead, which is what the WiM family does.
139    ///
140    /// [`route_with_sparte`]: PidRouter::route_with_sparte
141    /// [`table`]: PidRouter::table
142    commodity_table: HashMap<(u32, Sparte), Box<str>>,
143    /// Tracks which module registered each PID for conflict detection.
144    ///
145    /// Populated by [`register_with_module`]; used to produce actionable
146    /// panic messages when two modules register the same PID to different workflows.
147    ///
148    /// [`register_with_module`]: PidRouter::register_with_module
149    registered_by: HashMap<u32, Box<str>>,
150}
151
152impl PidRouter {
153    /// Create an empty router.
154    #[must_use]
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Register `pid` as routing to `workflow_name`.
160    ///
161    /// If `pid` was already registered, the previous mapping is silently
162    /// replaced. Call this only at build time (via [`EngineModule::register_pids`]);
163    /// the method is `&mut self` to prevent accidental runtime mutation once the
164    /// router is sealed inside [`EngineContext`].
165    ///
166    /// Accepts any string — `&'static str`, `String`, or `Box<str>`.
167    ///
168    /// For conflict-detected registration (preferred in multi-module builds),
169    /// use [`register_with_module`] instead.
170    ///
171    /// [`EngineModule::register_pids`]: crate::builder::EngineModule::register_pids
172    /// [`EngineContext`]: crate::builder::EngineContext
173    /// [`register_with_module`]: PidRouter::register_with_module
174    pub fn register(&mut self, pid: u32, workflow_name: impl Into<Box<str>>) {
175        let wf = workflow_name.into();
176        self.table.insert(pid, wf);
177    }
178
179    /// Register `pid` → `workflow_name` with module-attribution conflict detection.
180    ///
181    /// # Panics
182    ///
183    /// Panics at **build time** (before the engine starts) if `pid` is already
184    /// registered to a *different* workflow name by a *different* module. Two
185    /// modules registering the same PID to the **same** workflow are silently
186    /// accepted (idempotent).
187    ///
188    /// Use [`DeploymentRoles`] to prevent two modules from registering the same
189    /// PID when only one role is active:
190    ///
191    /// ```rust,ignore
192    /// // Both GPKE (NB role) and WiM (nMSB role) register 19001 → different workflows.
193    /// // Set explicit roles so only one module's conditional block fires:
194    /// use mako_engine::marktrolle::{DeploymentRoles, Marktrolle};
195    /// let roles = DeploymentRoles::from_roles([Marktrolle::Nb]);
196    /// // Now only GPKE registers 19001 → "gpke-konfiguration".
197    /// ```
198    ///
199    /// [`DeploymentRoles`]: crate::marktrolle::DeploymentRoles
200    pub fn register_with_module(
201        &mut self,
202        pid: u32,
203        workflow_name: impl Into<Box<str>>,
204        module: &str,
205    ) {
206        let wf = workflow_name.into();
207        if let Some(existing_wf) = self.table.get(&pid)
208            && *existing_wf != wf
209        {
210            let existing_mod = self
211                .registered_by
212                .get(&pid)
213                .map_or("<unknown>", Box::as_ref);
214            panic!(
215                "PID {pid} routing conflict:\n  \
216                     module '{module}' tried to register PID {pid} → '{wf}'\n  \
217                     but it was already registered → '{existing_wf}' by module '{existing_mod}'\n  \
218                     Hint: use DeploymentRoles to prevent conflicting modules from \
219                     both registering shared PIDs (e.g. 19001/19002 are claimed by \
220                     gpke-konfiguration for NB role and wim-geraeteubernahme for nMSB role).\n  \
221                     Set EngineBuilder::with_deployment_roles(DeploymentRoles::nb()) to keep \
222                     only the NB-role registration."
223            );
224        }
225        self.table.insert(pid, wf);
226        self.registered_by.insert(pid, module.into());
227    }
228
229    /// Register `pid` → `workflow_name` for a specific commodity ([`Sparte`]).
230    ///
231    /// Use this for PIDs that map to **different workflows depending on whether
232    /// the message concerns electricity (Strom) or gas (Gas)**. At runtime call
233    /// [`route_with_sparte`] to prefer the commodity-specific entry over the
234    /// unambiguous fallback registered via [`register`].
235    ///
236    /// ```rust,ignore
237    /// // In GpkeModule (Strom):
238    /// router.register_with_sparte(17115, Sparte::Strom, "gpke-sperrung");
239    ///
240    /// // In GeliGasModule (Gas):
241    /// router.register_with_sparte(17115, Sparte::Gas, "geli-gas-sperrung-lf");
242    /// ```
243    ///
244    /// [`route_with_sparte`]: PidRouter::route_with_sparte
245    /// [`register`]: PidRouter::register
246    pub fn register_with_sparte(
247        &mut self,
248        pid: u32,
249        sparte: Sparte,
250        workflow_name: impl Into<Box<str>>,
251    ) {
252        self.commodity_table
253            .insert((pid, sparte), workflow_name.into());
254    }
255
256    /// Look up the workflow name for `pid`, preferring the commodity-qualified
257    /// entry for `sparte` over the unambiguous fallback.
258    ///
259    /// Resolution order:
260    /// 1. `commodity_table[(pid, sparte)]` — registered via [`register_with_sparte`]
261    /// 2. `table[pid]` — registered via [`register`] (unambiguous fallback)
262    ///
263    /// Returns `None` when neither table has an entry for `pid`.
264    ///
265    /// # Example
266    ///
267    /// ```rust
268    /// use mako_engine::pid_router::PidRouter;
269    /// use mako_engine::types::Sparte;
270    ///
271    /// let mut r = PidRouter::new();
272    /// r.register(17115, "gpke-sperrung");                            // Strom fallback
273    /// r.register_with_sparte(17115, Sparte::Strom, "gpke-sperrung");
274    /// r.register_with_sparte(17115, Sparte::Gas,   "geli-gas-sperrung-lf");
275    ///
276    /// assert_eq!(r.route_with_sparte(17115, Sparte::Strom), Some("gpke-sperrung"));
277    /// assert_eq!(r.route_with_sparte(17115, Sparte::Gas),   Some("geli-gas-sperrung-lf"));
278    /// assert_eq!(r.route(17115),                             Some("gpke-sperrung"));
279    /// ```
280    ///
281    /// [`register_with_sparte`]: PidRouter::register_with_sparte
282    /// [`register`]: PidRouter::register
283    #[must_use]
284    pub fn route_with_sparte(&self, pid: u32, sparte: Sparte) -> Option<&str> {
285        self.commodity_table
286            .get(&(pid, sparte))
287            .or_else(|| self.table.get(&pid))
288            .map(Box::as_ref)
289    }
290
291    /// Look up the workflow name for `pid`.
292    ///
293    /// Returns `None` when `pid` has not been registered. The caller should
294    /// dead-letter the message and return an appropriate error to the sender
295    /// rather than panicking.
296    #[must_use]
297    pub fn route(&self, pid: u32) -> Option<&str> {
298        self.table.get(&pid).map(Box::as_ref)
299    }
300
301    /// Return an iterator over all registered PID values.
302    ///
303    /// Useful for validation (e.g. comparing against PIDs declared in
304    /// AHB profile JSON files to detect missing workflow implementations).
305    pub fn registered_pids(&self) -> impl Iterator<Item = u32> + '_ {
306        self.table.keys().copied()
307    }
308
309    /// Return an iterator over all commodity-qualified routing entries.
310    ///
311    /// Yields `(pid, sparte, workflow_name)` tuples.
312    ///
313    /// Used by [`EngineBuilder`] to copy sparte-qualified entries from a scratch
314    /// router into the real [`PidRouter`] without conflict detection (commodity
315    /// entries use distinct `(pid, sparte)` keys and never conflict across modules).
316    ///
317    /// [`EngineBuilder`]: crate::builder::EngineBuilder
318    pub fn registered_commodity_entries(&self) -> impl Iterator<Item = (u32, Sparte, &str)> + '_ {
319        self.commodity_table
320            .iter()
321            .map(|((pid, sparte), wf)| (*pid, *sparte, wf.as_ref()))
322    }
323
324    /// Return a sorted, deduplicated list of all workflow names registered in
325    /// this router (across both the unambiguous table and commodity-qualified
326    /// table).
327    ///
328    /// Used at startup by `validate_dispatch_completeness` to verify that every
329    /// workflow name reachable via PID routing has a matching arm in the
330    /// `EdifactIngestDispatcher`.
331    #[must_use]
332    pub fn workflow_names(&self) -> Vec<&str> {
333        let mut names: Vec<&str> = self
334            .table
335            .values()
336            .map(Box::as_ref)
337            .chain(self.commodity_table.values().map(Box::as_ref))
338            .collect();
339        names.sort_unstable();
340        names.dedup();
341        names
342    }
343
344    /// Return the number of registered PID mappings.
345    #[must_use]
346    pub fn len(&self) -> usize {
347        self.table.len()
348    }
349
350    /// Return `true` when no PIDs have been registered.
351    #[must_use]
352    pub fn is_empty(&self) -> bool {
353        self.table.is_empty()
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn route_registered_pid() {
363        let mut r = PidRouter::new();
364        r.register(55001, "GpkeSupplierChange");
365        assert_eq!(r.route(55001), Some("GpkeSupplierChange"));
366    }
367    #[test]
368    fn route_unregistered_pid_returns_none() {
369        let r = PidRouter::new();
370        assert_eq!(r.route(55001), None);
371    }
372
373    #[test]
374    fn register_overwrites_previous_mapping() {
375        let mut r = PidRouter::new();
376        r.register(55001, "OldWorkflow");
377        r.register(55001, "NewWorkflow");
378        assert_eq!(r.route(55001), Some("NewWorkflow"));
379        assert_eq!(r.len(), 1);
380    }
381
382    #[test]
383    fn registered_pids_covers_all_entries() {
384        let mut r = PidRouter::new();
385        r.register(55001, "A");
386        r.register(55002, "B");
387        r.register(11001, "C");
388
389        let mut pids: Vec<u32> = r.registered_pids().collect();
390        pids.sort_unstable();
391        assert_eq!(pids, [11001, 55001, 55002]);
392    }
393
394    #[test]
395    fn multiple_pids_same_workflow() {
396        let mut r = PidRouter::new();
397        r.register(55001, "GpkeSupplierChange");
398        r.register(55002, "GpkeSupplierChange");
399        r.register(55003, "GpkeSupplierChange");
400
401        assert_eq!(r.len(), 3);
402        for pid in [55001, 55002, 55003] {
403            assert_eq!(r.route(pid), Some("GpkeSupplierChange"));
404        }
405    }
406
407    #[test]
408    fn is_empty_and_len() {
409        let mut r = PidRouter::new();
410        assert!(r.is_empty());
411        r.register(55001, "W");
412        assert!(!r.is_empty());
413        assert_eq!(r.len(), 1);
414    }
415
416    // ── Commodity-aware routing ───────────────────────────────────────────────
417    //
418    // The mechanism is exercised on ORDERS 17115 (Sperrauftrag), which GPKE and
419    // GeLi Gas answer with genuinely different processes. A PID whose two
420    // Sparten differ only in Frist or Codeliste does not belong here: one
421    // workflow takes the Sparte as an argument instead.
422
423    #[test]
424    fn route_with_sparte_prefers_commodity_entry() {
425        use crate::types::Sparte;
426        let mut r = PidRouter::new();
427        r.register(17115, "gpke-sperrung"); // unambiguous fallback
428        r.register_with_sparte(17115, Sparte::Strom, "gpke-sperrung");
429        r.register_with_sparte(17115, Sparte::Gas, "geli-gas-sperrung-lf");
430
431        assert_eq!(
432            r.route_with_sparte(17115, Sparte::Strom),
433            Some("gpke-sperrung")
434        );
435        assert_eq!(
436            r.route_with_sparte(17115, Sparte::Gas),
437            Some("geli-gas-sperrung-lf")
438        );
439        // Unambiguous route() is unaffected by commodity table.
440        assert_eq!(r.route(17115), Some("gpke-sperrung"));
441    }
442
443    #[test]
444    fn route_with_sparte_falls_back_to_unambiguous() {
445        use crate::types::Sparte;
446        let mut r = PidRouter::new();
447        // No commodity entry — only unambiguous.
448        r.register(55001, "GpkeSupplierChange");
449
450        assert_eq!(
451            r.route_with_sparte(55001, Sparte::Strom),
452            Some("GpkeSupplierChange")
453        );
454        assert_eq!(
455            r.route_with_sparte(55001, Sparte::Gas),
456            Some("GpkeSupplierChange")
457        );
458    }
459
460    #[test]
461    fn route_with_sparte_returns_none_for_unregistered() {
462        use crate::types::Sparte;
463        let r = PidRouter::new();
464        assert_eq!(r.route_with_sparte(17115, Sparte::Strom), None);
465        assert_eq!(r.route_with_sparte(23001, Sparte::Gas), None);
466    }
467
468    #[test]
469    fn route_with_sparte_gas_only_deployment() {
470        use crate::types::Sparte;
471        // Gas-standalone: only Gas entries; no Strom fallback registered.
472        let mut r = PidRouter::new();
473        r.register(17115, "geli-gas-sperrung-lf"); // unambiguous (Gas-standalone)
474        r.register_with_sparte(17115, Sparte::Gas, "geli-gas-sperrung-lf");
475
476        assert_eq!(
477            r.route_with_sparte(17115, Sparte::Gas),
478            Some("geli-gas-sperrung-lf")
479        );
480        // Strom falls back to unambiguous (no Strom-specific entry exists).
481        assert_eq!(
482            r.route_with_sparte(17115, Sparte::Strom),
483            Some("geli-gas-sperrung-lf")
484        );
485    }
486
487    #[test]
488    fn route_with_sparte_combined_deployment_keeps_both_families() {
489        use crate::types::Sparte;
490        let mut r = PidRouter::new();
491        // GpkeModule registers the Sperrprozess ORDERS as Strom:
492        for pid in [17115_u32, 17116, 17117] {
493            r.register(pid, "gpke-sperrung");
494            r.register_with_sparte(pid, Sparte::Strom, "gpke-sperrung");
495        }
496        // GeliGasModule registers the same PIDs as Gas:
497        for pid in [17115_u32, 17116, 17117] {
498            r.register_with_sparte(pid, Sparte::Gas, "geli-gas-sperrung-lf");
499        }
500
501        for pid in [17115_u32, 17116, 17117] {
502            assert_eq!(
503                r.route_with_sparte(pid, Sparte::Strom),
504                Some("gpke-sperrung"),
505                "PID {pid} Strom"
506            );
507            assert_eq!(
508                r.route_with_sparte(pid, Sparte::Gas),
509                Some("geli-gas-sperrung-lf"),
510                "PID {pid} Gas"
511            );
512        }
513    }
514}