mako-wim 0.5.0

WiM process engine for German smart-meter market communication (Wechsel des Messstellenbetreibers)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
//! `mako-wim` — WiM (Wechselprozesse im Messwesen Strom) process engine for
//! German smart-meter market communication (BDEW MaKo).
//!
//! ## Process family
//!
//! WiM governs the switching processes for metering point operators in the
//! German electricity smart-meter rollout, regulated by the MsbG and BDEW
//! WiM process documentation:
//!
//! | Process | PIDs | Message | Module | Status |
//! |---|---|---|---|---|
//! | Anmeldung MSB (nMSB → NB) | 55042 | UTILMD | `geraetewechsel` | ✅ Implemented |
//! | Kündigung MSB (nMSB → NB) | 55039 | UTILMD | `geraetewechsel` | ✅ Registered (shared workflow) |
//! | Ende MSB / Abmeldung (NB → MSBN) | 55051 | UTILMD | `geraetewechsel` | ✅ Registered (shared workflow) |
//! | Verpflichtungsanfrage (NB → MSBN) | 55168 | UTILMD | `geraetewechsel` | ✅ Registered (shared workflow) |
//! | Bestellung Geräteübernahmeangebot | 17001–17011 | ORDERS | `geraeteubernahme` | ✅ Implemented |
//! | Stammdaten Anfrage / Übermittlung | 17132 (req), 17102–17133 (resp) | ORDERS | `stammdaten` | ✅ Implemented |
//! | Preisanfrage (REQOTE/QUOTES) | 35001–35005 (REQOTE in), 15001–15005 (QUOTES in) | REQOTE, QUOTES | `preisanfrage` | ✅ Implemented |
//! | Preisliste (PRICAT) | 27001–27003 | PRICAT | `preisliste` | ✅ Implemented |
//! | Stornierung Sperr-/Entsperrauftrag | 39000 | ORDCHG | `stornierung` | ✅ Implemented |
//! | WiM-Rechnung / MSB-Rechnung (INVOIC) | 31003, 31009 | INVOIC | `rechnung` | ✅ Implemented (stub, full settlement pending) |
//!
//! ## Architecture
//!
//! Each BDEW process variant is a separate [`mako_engine::workflow::Workflow`]
//! implementation. This crate contains **only pure domain logic** — no I/O,
//! no EDIFACT parsing, no network calls.
//!
//! Parsing and validation of raw EDIFACT bytes must happen at the transport
//! boundary (AS4 reception layer), **before** constructing a domain command.
//! The workflow `handle()` function receives pre-extracted domain values:
//!
//! ```text
//! AS4 transport layer
//!   └── parse raw bytes          (edi-energy)
//!       └── validate             (edi-energy)
//!           └── extract fields   (application code)
//!               └── DeviceChangeCommand { pid, melo_id, device_id, … }
//!                   └── Process::execute(cmd)  ← pure domain logic here
//! ```
//!
//! ## Key regulatory distinction from GPKE
//!
//! | Aspect | GPKE | WiM |
//! |---|---|---|
//! | APERAK Frist | **24 h** wall-clock | **5 Werktage** |
//! | Frist helper | `fristen::add_hours(24)` | `fristen::add_werktage(5, BdewMaKo)` |
//! | Governing rule | BK6-22-024 | BK6-24-174 |
//!
//! ## Command construction example
//!
//! ```rust,ignore
//! use edi_energy::{AnyMessage, EdiEnergyMessage, Platform};
//! use mako_wim::geraetewechsel::{WimDeviceChangeWorkflow, DeviceChangeCommand};
//!
//! let msg    = Platform::with_all_profiles().parse(&raw_bytes)?;
//! let report = msg.validate()?;
//! let AnyMessage::Utilmd(u) = &msg else { anyhow::bail!("not UTILMD") };
//!
//! let cmd = DeviceChangeCommand::ReceiveUtilmd {
//!     pid:               msg.detect_pruefidentifikator()?,
//!     sender:            u.sender().and_then(|n| n.party_id.clone()).unwrap_or_default(),
//!     receiver:          u.receiver().and_then(|n| n.party_id.clone()).unwrap_or_default(),
//!     melo_id:           u.transactions().first()
//!                         .and_then(|t| t.ide.object_id.clone()).unwrap_or_default(),
//!     device_id:         u.transactions().first()
//!                         .and_then(|t| t.device_id().cloned()).unwrap_or_default(),
//!     document_date:     u.dtm().iter().find(|d| d.is_document_date())
//!                         .and_then(|d| d.value.clone()).unwrap_or_default(),
//!     message_ref:       msg.message_ref().to_owned(),
//!     validation_passed: report.is_valid(),
//!     validation_errors: report.errors().iter()
//!                         .map(|i| format!("{i}")).collect(),
//! };
//!
//! process.execute(cmd).await?;
//! ```

#![deny(missing_docs)]

pub mod geraeteubernahme;
pub mod geraetewechsel;
pub mod insrpt;
pub mod preisanfrage;
pub mod preisliste;
pub mod rechnung;
pub mod stammdaten;
pub mod steuerungsauftrag;
pub mod stornierung;
pub mod technik_aenderung;

pub use geraeteubernahme::{
    ANFRAGE_PIDS, BESTELLUNG_PIDS, GeraeteubernahmeCommand, GeraeteubernahmeData,
    GeraeteubernahmeEvent, GeraeteubernahmeProjection, GeraeteubernahmeRecord,
    GeraeteubernahmeRecordData, GeraeteubernahmeState,
    ORDRSP_DEADLINE_LABEL as GERAETEUBERNAHME_ORDRSP_DEADLINE_LABEL,
    STORNIERUNG_PIDS as GERAETEUBERNAHME_STORNIERUNG_PIDS, WimGeraeteubernahmeWorkflow,
};
pub use geraetewechsel::{
    APERAK_WINDOW_LABEL as GERAETEWECHSEL_APERAK_WINDOW_LABEL, DeviceChangeCommand,
    DeviceChangeData, DeviceChangeEvent, DeviceChangeProjection, DeviceChangeRecord,
    DeviceChangeState, WORKFLOW_NAME, WimDeviceChangeWorkflow,
};
pub use insrpt::{
    ANTWORT_WINDOW_LABEL as INSRPT_ANTWORT_WINDOW_LABEL, INSRPT_ANFRAGE_PIDS, INSRPT_ANTWORT_PIDS,
    StorungsmeldungCommand, StorungsmeldungData, StorungsmeldungEvent, StorungsmeldungState,
    WORKFLOW_NAME as INSRPT_WORKFLOW_NAME, WimInsrptWorkflow,
};
pub use preisanfrage::{
    PREISANFRAGE_DEADLINE_LABEL, PreisanfrageCommand, PreisanfrageData, PreisanfrageEvent,
    PreisanfrageState, QUOTES_PIDS, REQOTE_PIDS, WimPreisanfrageWorkflow,
};
pub use preisliste::{
    PRICAT_PIDS, PreislisteCommand, PreislisteData, PreislisteEvent, PreislisteState,
    WimPreislisteWorkflow,
};
pub use rechnung::{
    WIM_COMDIS_ABLEHNUNG_PID, WIM_INVOIC_PIDS, WIM_RECHNUNG_WINDOW_LABEL, WIM_REMADV_PIDS,
    WORKFLOW_NAME as RECHNUNG_WORKFLOW_NAME, WimRechnungCommand, WimRechnungEvent,
    WimRechnungState, WimRechnungWorkflow,
};
pub use stammdaten::{
    ANFORDERUNG_PID, STAMMDATEN_DEADLINE_LABEL, StammdatenCommand, StammdatenData, StammdatenEvent,
    StammdatenProjection, StammdatenRecord, StammdatenRecordData, StammdatenState,
    UEBERMITTLUNG_PIDS, WimStammdatenWorkflow,
};
pub use steuerungsauftrag::{
    STEUERUNGSAUFTRAG_DEADLINE_LABEL, SteuerungsCommandType, SteuerungsauftragCommand,
    SteuerungsauftragData, SteuerungsauftragEvent, SteuerungsauftragState,
    WORKFLOW_NAME as STEUERUNGSAUFTRAG_WORKFLOW_NAME, WimSteuerungsauftragWorkflow,
};
pub use stornierung::{
    ABLEHNUNG_PID, BESTAETIGUNG_PID, STORNIERUNG_DEADLINE_LABEL, STORNIERUNG_PID,
    StornierungCommand, StornierungData, StornierungEvent, StornierungProjection,
    StornierungRecord, StornierungRecordData, StornierungState, WimStornierungWorkflow,
};
pub use technik_aenderung::{
    AuftragData as TechnikAenderungAuftragData, ORDERS_PIDS as TECHNIK_AENDERUNG_ORDERS_PIDS,
    ORDRSP_PIDS as TECHNIK_AENDERUNG_ORDRSP_PIDS, TechnikAenderungCommand, TechnikAenderungEvent,
    TechnikAenderungState, WORKFLOW_NAME as TECHNIK_AENDERUNG_WORKFLOW_NAME,
    WimTechnikAenderungWorkflow,
};

// ── EngineModule ──────────────────────────────────────────────────────────────

/// Engine module for the WiM process family.
///
/// Registers all WiM `Prüfidentifikator` values into the
/// [`mako_engine::pid_router::PidRouter`] at engine startup:
///
/// | PID(s) | Workflow key | Module | Role |
/// |---|---|---|---|
/// | 55039 | `wim-device-change` | Kündigung MSB (MSBN → NB) | any |
/// | 55042 | `wim-device-change` | Anmeldung MSB (MSBN → NB) | any |
/// | 55051 | `wim-device-change` | Ende MSB / Abmeldung (NB → MSBN) | any |
/// | 55168 | `wim-device-change` | Verpflichtungsanfrage (NB → MSBN) | any |
/// | 17001–17011 | `wim-geraeteubernahme` | Geräteübernahme ORDERS (nMSB → NB) | any |
/// | 17132 | `wim-stammdaten` | Stammdaten Anforderung Strom (NB → MSB), MSB role | any |
/// | 17102–17133 | `wim-stammdaten` | Stammdatenübermittlung responses (MSB → NB), NB role | **Nb only** |
/// | 39000 | `wim-stornierung` | Stornierung (ORDCHG) | any |
/// | 19001, 19002 | `wim-geraeteubernahme` | ORDRSP Bestellbestätigung/Ablehnung from NB | **nMSB only** |
/// | 19015, 19016 | `wim-geraeteubernahme` | ORDRSP Gerätewechselabsicht Bestätigung/Ablehnung | **nMSB only** |
///
/// ## Role-conditional PIDs (ORDRSP 19001/19002/19015/19016)
///
/// When this `makod` instance serves the **nMSB** role it sends outbound ORDERS
/// to the NB and receives inbound ORDRSP responses. These PIDs are only registered
/// when [`DeploymentRoles`] contains [`Marktrolle::Nmsb`]:
///
/// | ORDRSP PID | AHB process name | Responds to ORDERS |
/// |---|---|---|
/// | 19001 | Bestellbestätigung | 17001 (Bestellung Geräteübernahmeangebot) |
/// | 19002 | Ablehnung der Bestellung | 17001 (Bestellung Geräteübernahmeangebot) |
/// | 19015 | Bestätigung Gerätewechselabsicht | 17009 (Ankündigung Gerätewechselabsicht) |
/// | 19016 | Ablehnung Gerätewechselabsicht | 17009 (Ankündigung Gerätewechselabsicht) |
///
/// **Conflict note:** PIDs 19001/19002 are also used by GPKE Konfiguration when
/// the instance is NB (receiving ORDRSP from the MSB after sending ORDERS 17134/17135).
/// Use [`DeploymentRoles::nmsb()`] for nMSB-only deployments and [`DeploymentRoles::nb()`]
/// for NB-only deployments to prevent both modules from registering these PIDs simultaneously.
///
/// [`DeploymentRoles`]: mako_engine::marktrolle::DeploymentRoles
/// [`Marktrolle::Nmsb`]: mako_engine::marktrolle::Marktrolle::Nmsb
/// [`DeploymentRoles::nmsb()`]: mako_engine::marktrolle::DeploymentRoles::nmsb
/// [`DeploymentRoles::nb()`]: mako_engine::marktrolle::DeploymentRoles::nb
pub struct WimModule;

impl mako_engine::builder::EngineModule for WimModule {
    fn name(&self) -> &'static str {
        "wim"
    }

    fn workflow_names(&self) -> &'static [&'static str] {
        &[
            "wim-device-change",
            "wim-geraeteubernahme",
            "wim-stammdaten",
            "wim-stornierung",
            "wim-steuerungsauftrag",
            "wim-preisanfrage",
            "wim-preisliste",
            "wim-rechnung",
            insrpt::WORKFLOW_NAME,
            technik_aenderung::WORKFLOW_NAME,
        ]
    }

    fn register_pids_with_roles(
        &self,
        router: &mut mako_engine::pid_router::PidRouter,
        roles: &mako_engine::marktrolle::DeploymentRoles,
    ) {
        // UTILMD WiM MSB-Wechsel family (PIDs 55039, 55042, 55051, 55168).
        //
        // 55039 — Kündigung MSB (MSBN → NB): incoming MSB initiates cancellation.
        // 55042 — Anmeldung MSB (MSBN → NB): new MSB initiates change.
        // 55051 — Ende MSB / Abmeldung (NB → MSBN): NB terminates MSB relationship.
        // 55168 — Verpflichtungsanfrage (NB → MSBN): grid operator obligation request.
        //
        // All four share WimDeviceChangeWorkflow; the PID is carried in the
        // DeviceChangeData and available for business-logic branching.
        for pid in [55_039_u32, 55_042, 55_051, 55_168] {
            router.register(pid, "wim-device-change");
        }

        // ORDERS 17001–17011 — Geräteübernahme (Anfrage, Bestellung, Stornierung).
        for &pid in geraeteubernahme::ANFRAGE_PIDS
            .iter()
            .chain(geraeteubernahme::BESTELLUNG_PIDS)
            .chain(geraeteubernahme::STORNIERUNG_PIDS)
        {
            router.register(pid, "wim-geraeteubernahme");
        }

        // nMSB role: inbound ORDRSP responses from NB to nMSB ORDERS.
        //
        // ONLY registered when Nmsb is explicitly declared in DeploymentRoles
        // (not triggered by DeploymentRoles::all(), which is the backward-compatible
        // default where GPKE owns 19001/19002 unchanged).
        //
        // When makod acts as nMSB it sends ORDERS 17001/17009 outbound (via outbox)
        // and receives inbound ORDRSP responses back from the NB. These PIDs are
        // only registered when the nMSB role is active, preventing routing conflicts
        // with GPKE Konfiguration on NB instances (which also uses 19001/19002).
        //
        // PID 19001/19002 (AHB fv20251001): ORDRSP Bestellbestätigung/Ablehnung
        //   → response to ORDERS 17001 (Bestellung Geräteübernahmeangebot)
        // PID 19015/19016 (AHB fv20251001): ORDRSP Gerätewechselabsicht Bestätigung/Ablehnung
        //   → response to ORDERS 17009 (Ankündigung Gerätewechselabsicht, §14a EnWG)
        if !roles.is_all() && roles.contains(mako_engine::marktrolle::Marktrolle::Nmsb) {
            for pid in [19_001_u32, 19_002, 19_015, 19_016] {
                // register_with_module enforces the documented guarantee: if both NB
                // (GPKE Konfiguration) and nMSB (WiM Geräteübernahme) roles are active
                // simultaneously, build() panics instead of silently overwriting the
                // conflicting registration.
                router.register_with_module(pid, "wim-geraeteubernahme", "wim");
            }
        }

        // ORDERS 17132 — Stammdaten Anforderung Strom (NB → MSB).
        //
        // When makod acts as MSB it receives this inbound (NB sends the request).
        // When makod acts as NB it sends this outbound via the outbox; the MSB responds
        // with one of the UEBERMITTLUNG_PIDS (17102–17133) which the NB then receives
        // inbound — those are registered below under the Nb role guard.
        //
        // Note: 17101 (“Anfrage zur Übermittlung von Stammdaten Gas”) is the GAS counterpart
        // and belongs to mako-wim-gas, not here.
        router.register(stammdaten::ANFORDERUNG_PID, "wim-stammdaten");

        // Nb role: inbound Stammdatenübermittlung responses (MSB → NB).
        //
        // When makod acts as NB it sends ORDERS 17132 outbound and receives the MSB's
        // response (one of PIDs 17102–17133) inbound. These are registered only for
        // explicit Nb deployments to avoid routing conflicts on MSB-only instances.
        //
        // PIDs 17134/17135 are excluded: they are GPKE Konfiguration PIDs owned by
        // mako-gpke and must not be claimed by the WiM Stammdaten module.
        //
        // PIDs 17115–17117 are excluded: GPKE/AWH Sperrprozesse ORDERS PIDs
        // (Sperrauftrag / Aufhebung Sperrauftrag / Sperrung nicht möglich) owned by
        // mako-gpke as "gpke-sperrung".
        //
        // The following GPKE-owned PIDs fall inside the 17102–17133 range and must
        // not be claimed by wim-stammdaten to avoid ownership conflicts on combined NB
        // deployments (both GpkeModule and WimModule active):
        //
        //   17102 (gpke-datenabruf, Datenabruf Anfrage LF→NB)
        //   17110 (gpke-allokationsliste, Anforderung Allokationsliste)
        //   17113 (gpke-datenabruf, Weitere Datenabruf Anfrage)
        //   17114 (gpke-allokationsliste, Abmeldung Allokationsliste)
        //   17120 (gpke-konfiguration-aenderung, Bestellung Konfiguration LF→NB)
        //   17121 (gpke-konfiguration-aenderung, Bestellung Konfiguration LF→NB)
        //   17122 (gpke-konfiguration-aenderung, Bestellung Konfigurationsänderung)
        //   17123 (gpke-konfiguration-aenderung, Stornierung Konfigurationsbestellung)
        //   17128 (gpke-konfiguration-aenderung, Bestellung Konfiguration LF→MSB)
        //   17129 (gpke-konfiguration-aenderung, Bestellung Konfiguration LF→MSB)
        //   17130 (gpke-konfiguration-aenderung, Bestellung Konfigurationsänderung LF→MSB)
        //   17131 (gpke-konfiguration-aenderung, Stornierung Konfigurationsbestellung LF→MSB)
        //   17133 (gpke-konfiguration-aenderung, Bestellung Konfiguration Reklamation)
        //
        // Source: docs/pid-reference.md (generated from BDEW xlsx PID 3.3 + PID 4.0).
        #[rustfmt::skip]
        const GPKE_OWNED_IN_RANGE: &[u32] = &[
            17102, 17113,                        // gpke-datenabruf
            17110, 17114,                        // gpke-allokationsliste
            17120, 17121, 17122, 17123,          // gpke-konfiguration-aenderung (LF→NB)
            17128, 17129, 17130, 17131, 17133,   // gpke-konfiguration-aenderung (LF→MSB)
            // 17115, 17116, 17117 already excluded by the matches!() guard below
        ];
        if !roles.is_all() && roles.contains(mako_engine::marktrolle::Marktrolle::Nb) {
            for pid in stammdaten::UEBERMITTLUNG_PIDS {
                if matches!(pid, 17115..=17117) {
                    // Sperrung PIDs — owned by mako-gpke (gpke-sperrung).
                    continue;
                }
                if GPKE_OWNED_IN_RANGE.contains(&pid) {
                    // GPKE-owned PIDs — must not be claimed by wim-stammdaten.
                    continue;
                }
                router.register(pid, "wim-stammdaten");
            }
        }

        // REQOTE 35001–35005 (Preisanfrage) and QUOTES 15001–15005 (Angebot).
        for &pid in preisanfrage::REQOTE_PIDS
            .iter()
            .chain(preisanfrage::QUOTES_PIDS)
        {
            router.register(pid, "wim-preisanfrage");
        }

        // PRICAT 27001–27003 (Preisliste).
        for &pid in preisliste::PRICAT_PIDS {
            router.register(pid, "wim-preisliste");
        }

        // ORDCHG 39000 — Stornierung Sperr-/Entsperrauftrag.
        // Response PIDs (39001 Bestätigung, 39002 Ablehnung) are outbox entries.
        router.register(stornierung::STORNIERUNG_PID, "wim-stornierung");

        // INVOIC 31003 (WiM-Rechnung) and 31009 (MSB-Rechnung).
        //
        // These PIDs are explicitly excluded from mako-gpke's INVOIC_PIDS array.
        // Without registration here, all inbound WiM-domain INVOIC messages would
        // be silently dead-lettered and no CONTRL acknowledgement would be sent,
        // violating the AS4 acknowledgement obligation (BDEW AS4-Profile §5).
        //
        // The WimRechnungWorkflow provides a complete state machine;
        // full settlement/dispute business logic is marked for follow-up in TODO.md.
        for &pid in rechnung::WIM_INVOIC_PIDS {
            router.register(pid, "wim-rechnung");
        }

        // REMADV 33001–33002 — inbound payment advice for WiM billing (invoicer role).
        //
        // After the NB sends INVOIC 31009 (MSB-Rechnung), the payer (MSB) sends
        // back a REMADV (33001 = Bestätigung, 33002 = Ablehnung). Without this
        // registration, all REMADV messages for WiM billing are silently dropped.
        //
        // GPKE billing also registers 33003/33004 (Mehr-/Mindermenge REMADV);
        // WiM Strom only needs 33001/33002 — the others belong to GPKE Teil 2/3.
        // Both registrations coexist: the makod router checks the workflow context
        // (conversation ID) when routing to the correct process stream instance.
        //
        // Source: REMADV AHB 1.0, WiM Strom Teil 1, BK6-24-174.
        for &pid in rechnung::WIM_REMADV_PIDS {
            router.register(pid, "wim-rechnung");
        }

        // COMDIS 29001 — inbound Ablehnung REMADV (invoicer rejects payer's REMADV).
        //
        // Shared PID with GPKE billing. The router dispatches to the correct
        // workflow instance via conversation ID correlation.
        //
        // Source: COMDIS AHB 1.0, WiM Strom Teil 1, BK6-24-174.
        router.register(rechnung::WIM_COMDIS_ABLEHNUNG_PID, "wim-rechnung");

        // IFTSTA WiM PIDs 21009–21018 (MSB-Wechsel status messages).
        //
        // These are Vollzugsmeldungen and process-status notifications that
        // accompany the WiM UTILMD device-change process. All are routed to
        // `wim-device-change` for correlation via conversation ID (CI tag).
        for &pid in geraetewechsel::IFTSTA_PIDS {
            router.register(pid, "wim-device-change");
        }

        // `wim-steuerungsauftrag` is intentionally NOT registered here.
        //
        // The Steuerungsauftrag workflow is driven exclusively by the BDEW
        // API-Webdienste Strom `controlMeasuresV1` REST channel (BDEW
        // API-Guideline 1.0a). There is no EDIFACT message type for this
        // workflow; it receives no inbound PID dispatch from the `PidRouter`.
        // The REST adapter (`energy-api`) creates process commands directly.
        // Do not add EDIFACT PID registrations for this workflow.

        // INSRPT Störungsmeldungen (WiM Strom Teil 2).
        //
        // 23001: Störungsmeldung (LF → MSB) — APERAK Frist 5 Werktage (BK6-24-174).
        // 23003–23012: Antwort/Ergebnisbericht/Informationsmeldung (MSB → LF).
        //
        // PIDs 23001/23003/23004/23008 are shared with WiM Gas (10 WT).  In a combined
        // Strom+Gas deployment both WimModule and WimGasModule register these PIDs — each
        // with their respective Sparte so that `route_with_sparte` can select the correct
        // workflow at ingest time:
        //
        //   route_with_sparte(23001, Sparte::Strom) → "wim-insrpt"        (5 WT)
        //   route_with_sparte(23001, Sparte::Gas)   → "wim-gas-insrpt"    (10 WT)
        //
        // The unambiguous `register` entry (Strom default) is the fallback for callers
        // that do not supply a Sparte (e.g. logging in the REST ingest endpoint).
        for &pid in insrpt::INSRPT_ANFRAGE_PIDS {
            router.register(pid, insrpt::WORKFLOW_NAME);
            router.register_with_sparte(
                pid,
                mako_engine::types::Sparte::Strom,
                insrpt::WORKFLOW_NAME,
            );
        }
        for &pid in insrpt::INSRPT_ANTWORT_PIDS {
            router.register(pid, insrpt::WORKFLOW_NAME);
            router.register_with_sparte(
                pid,
                mako_engine::types::Sparte::Strom,
                insrpt::WORKFLOW_NAME,
            );
        }

        // WiM Technikänderung — device/config change requests (ORDERS/ORDRSP).
        //
        // Covers LF→MSB (17003), ESA orders (17007/17008), MSB→MSB (17118).
        // ORDRSP: Bestätigung (19003/19005/19011) and Ablehnung (19004/19006/19007/19012).
        for &pid in technik_aenderung::ORDERS_PIDS {
            router.register(pid, technik_aenderung::WORKFLOW_NAME);
        }
        for &pid in technik_aenderung::ORDRSP_PIDS {
            router.register(pid, technik_aenderung::WORKFLOW_NAME);
        }
    }

    fn profile_requirements(&self) -> &'static [mako_engine::profile::ProfileRequirement] {
        use mako_engine::profile::ProfileRequirement;
        &[
            ProfileRequirement {
                message_type: "UTILMD",
                label: "UTILMD Strom (WiM Gerätewechsel)",
            },
            ProfileRequirement {
                message_type: "APERAK",
                label: "APERAK (WiM)",
            },
            ProfileRequirement {
                message_type: "ORDERS",
                label: "ORDERS (WiM Geräteübernahme/Stammdaten)",
            },
            ProfileRequirement {
                message_type: "ORDRSP",
                label: "ORDRSP (WiM Geräteübernahme Bestätigung 19001/19002/19015/19016)",
            },
            ProfileRequirement {
                message_type: "ORDCHG",
                label: "ORDCHG (WiM Stornierung)",
            },
            ProfileRequirement {
                message_type: "IFTSTA",
                label: "IFTSTA Statusmeldung (WiM 21007, 21009–21015, 21018, 21029–21032)",
            },
            ProfileRequirement {
                message_type: "INVOIC",
                label: "INVOIC WiM-Rechnung/MSB-Rechnung (31003, 31009)",
            },
            ProfileRequirement {
                message_type: "REMADV",
                label: "REMADV Zahlungsavis (WiM 33001/33002)",
            },
            ProfileRequirement {
                message_type: "COMDIS",
                label: "COMDIS Ablehnung REMADV (WiM 29001)",
            },
            ProfileRequirement {
                message_type: "INSRPT",
                label: "INSRPT Störungsmeldung (WiM Strom/Gas, 23001–23012)",
            },
        ]
    }

    fn configure(&self) -> Result<(), String> {
        // Verify that all static PID slices referenced by register_pids_with_roles()
        // are non-empty. An accidental empty const (e.g. from a codegen regression)
        // would silently mean the module registers no routes for an entire workflow
        // family, discoverable only on first inbound message.
        let named: &[(&str, &[u32])] = &[
            (
                "geraeteubernahme::BESTELLUNG_PIDS",
                geraeteubernahme::BESTELLUNG_PIDS,
            ),
            (
                "geraeteubernahme::STORNIERUNG_PIDS",
                geraeteubernahme::STORNIERUNG_PIDS,
            ),
            ("geraetewechsel::IFTSTA_PIDS", geraetewechsel::IFTSTA_PIDS),
            ("rechnung::WIM_INVOIC_PIDS", rechnung::WIM_INVOIC_PIDS),
            ("rechnung::WIM_REMADV_PIDS", rechnung::WIM_REMADV_PIDS),
            ("insrpt::INSRPT_ANFRAGE_PIDS", insrpt::INSRPT_ANFRAGE_PIDS),
            ("insrpt::INSRPT_ANTWORT_PIDS", insrpt::INSRPT_ANTWORT_PIDS),
            (
                "technik_aenderung::ORDERS_PIDS",
                technik_aenderung::ORDERS_PIDS,
            ),
            (
                "technik_aenderung::ORDRSP_PIDS",
                technik_aenderung::ORDRSP_PIDS,
            ),
        ];
        for (name, pids) in named {
            if pids.is_empty() {
                return Err(format!(
                    "wim: PID slice '{name}' is empty — \
                     at least one PID must be registered for each workflow group",
                ));
            }
        }
        // UEBERMITTLUNG_PIDS is a RangeInclusive<u32>, not a slice; verify it is non-empty.
        if stammdaten::UEBERMITTLUNG_PIDS.is_empty() {
            return Err("wim: stammdaten::UEBERMITTLUNG_PIDS is empty — \
                 at least one PID must be registered for the Stammdaten workflow"
                .to_owned());
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mako_engine::{
        builder::EngineModule,
        marktrolle::{DeploymentRoles, Marktrolle},
        pid_router::PidRouter,
    };

    /// Regression test for the NB-role PID conflict between WiM Stammdaten
    /// UEBERMITTLUNG_PIDS (17102..=17133) and GPKE-owned PIDs in that range.
    ///
    /// Before the fix, `!roles.is_all() && roles.contains(Nb)` caused WiM to
    /// register GPKE PIDs → "wim-stammdaten", overwriting GPKE's entries and
    /// silently misrouting messages.
    #[test]
    fn nb_role_sperrung_not_overwritten_by_stammdaten_range() {
        let nb = DeploymentRoles::from_roles([Marktrolle::Nb]);
        let mut router = PidRouter::new();
        // Simulate GPKE registration first (as it happens in makod startup order).
        router.register(17115, "gpke-sperrung");
        router.register(17116, "gpke-sperrung");
        router.register(17117, "gpke-sperrung");
        // GPKE-owned PIDs in the 17102..=17133 range
        router.register(17102, "gpke-datenabruf");
        router.register(17113, "gpke-datenabruf");
        router.register(17110, "gpke-allokationsliste");
        router.register(17114, "gpke-allokationsliste");
        router.register(17120, "gpke-konfiguration-aenderung");
        router.register(17121, "gpke-konfiguration-aenderung");
        router.register(17122, "gpke-konfiguration-aenderung");
        router.register(17123, "gpke-konfiguration-aenderung");
        router.register(17128, "gpke-konfiguration-aenderung");
        router.register(17129, "gpke-konfiguration-aenderung");
        router.register(17130, "gpke-konfiguration-aenderung");
        router.register(17131, "gpke-konfiguration-aenderung");
        router.register(17133, "gpke-konfiguration-aenderung");

        // WiM registration must NOT overwrite GPKE entries.
        WimModule.register_pids_with_roles(&mut router, &nb);

        // Sperrung PIDs must still route to gpke-sperrung, not wim-stammdaten.
        assert_eq!(
            router.route(17115),
            Some("gpke-sperrung"),
            "17115 must route to gpke-sperrung"
        );
        assert_eq!(
            router.route(17116),
            Some("gpke-sperrung"),
            "17116 must route to gpke-sperrung"
        );
        assert_eq!(
            router.route(17117),
            Some("gpke-sperrung"),
            "17117 must route to gpke-sperrung"
        );

        // GPKE-owned PIDs in range must not be overwritten by wim-stammdaten.
        assert_eq!(
            router.route(17102),
            Some("gpke-datenabruf"),
            "17102 must route to gpke-datenabruf"
        );
        assert_eq!(
            router.route(17113),
            Some("gpke-datenabruf"),
            "17113 must route to gpke-datenabruf"
        );
        assert_eq!(
            router.route(17110),
            Some("gpke-allokationsliste"),
            "17110 must route to gpke-allokationsliste"
        );
        assert_eq!(
            router.route(17114),
            Some("gpke-allokationsliste"),
            "17114 must route to gpke-allokationsliste"
        );
        assert_eq!(
            router.route(17120),
            Some("gpke-konfiguration-aenderung"),
            "17120 must route to gpke-konfiguration-aenderung"
        );
        assert_eq!(
            router.route(17122),
            Some("gpke-konfiguration-aenderung"),
            "17122 must route to gpke-konfiguration-aenderung"
        );
        assert_eq!(
            router.route(17128),
            Some("gpke-konfiguration-aenderung"),
            "17128 must route to gpke-konfiguration-aenderung"
        );
        assert_eq!(
            router.route(17133),
            Some("gpke-konfiguration-aenderung"),
            "17133 must route to gpke-konfiguration-aenderung"
        );

        // True WiM Stammdaten PIDs in the range must still resolve to wim-stammdaten.
        assert_eq!(
            router.route(17132),
            Some("wim-stammdaten"),
            "17132 (ANFORDERUNG_PID) must route to wim-stammdaten"
        );
        // 17103 is a genuine wim-stammdaten PID (not GPKE-owned).
        assert_eq!(
            router.route(17103),
            Some("wim-stammdaten"),
            "17103 must route to wim-stammdaten"
        );
    }

    /// Sanity: with DeploymentRoles::all() (default/dev), the NB gate does not
    /// fire at all, so the UEBERMITTLUNG range is not registered and any prior
    /// sperrung registration is undisturbed.
    #[test]
    fn all_roles_uebermittlung_gate_does_not_fire() {
        let all = DeploymentRoles::all();
        let mut router = PidRouter::new();
        router.register(17115, "gpke-sperrung");
        router.register(17116, "gpke-sperrung");
        router.register(17117, "gpke-sperrung");
        WimModule.register_pids_with_roles(&mut router, &all);

        assert_eq!(router.route(17115), Some("gpke-sperrung"));
        assert_eq!(router.route(17116), Some("gpke-sperrung"));
        assert_eq!(router.route(17117), Some("gpke-sperrung"));
        // 17132 ANFORDERUNG_PID should also be registered by the non-role-gated path.
        assert_eq!(router.route(17132), Some("wim-stammdaten"));
    }
}