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}