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}