Skip to main content

mako_wim/
lib.rs

1//! `mako-wim` — WiM (Wechselprozesse im Messwesen Strom) process engine for
2//! German smart-meter market communication (BDEW MaKo).
3//!
4//! ## Process family
5//!
6//! WiM governs the switching processes for metering point operators in the
7//! German electricity smart-meter rollout, regulated by the MsbG and BDEW
8//! WiM process documentation:
9//!
10//! | Process | PIDs | Message | Module | EBD |
11//! |---|---|---|---|---|
12//! | Anmeldung MSB (MSBN → NB) | 55042 → 55043/55044 | UTILMD | `geraetewechsel` | `E_0201` |
13//! | Kündigung MSB (MSBN → **MSBA**) | 55039 → 55040/55041 | UTILMD | `geraetewechsel` | `E_0200` |
14//! | Ende MSB / Abmeldung (**MSBA → NB**) | 55051 → 55052/55053 | UTILMD | `geraetewechsel` | `E_0202` |
15//! | Verpflichtungsanfrage (NB → **gMSB**) | 55168 → 55169/55170 | UTILMD | `geraetewechsel` | `E_0240` |
16//! | Weiterverpflichtung (**NB → MSBA**) | 17002 → 19003/19004 | ORDERS/ORDRSP | `weiterverpflichtung` | `E_0203` |
17//! | Ersteinbau iMS (**gMSB → wMSB**) | 21029 → 21030/21031 | IFTSTA | `ersteinbau` | `E_0233` |
18//! | Geräteübernahme Bestellung (MSBN → MSBA) | 17001 → 19001/19002 | ORDERS/ORDRSP | `geraeteubernahme` | `E_0247` |
19//! | Anzeige Gerätewechselabsicht (MSBN → MSBA) | 17009 → 19015/19016 | ORDERS/ORDRSP | `geraeteubernahme` | `E_0204` |
20//! | Messlokationsänderung (NB/LF → MSB) | 17011/17118 → 19005/19006 | ORDERS/ORDRSP | `technik_aenderung` | `E_0249`/`E_0250` |
21//! | Stammdaten Anfrage / Übermittlung | 17132 (req), 17102–17133 (resp) | ORDERS | `stammdaten` | — |
22//! | Preisanfrage (REQOTE/QUOTES) | 35001/35002/35004/35005 → 15001/15002/15004/15005 | REQOTE, QUOTES | `preisanfrage` | — |
23//! | Rechnungsabwicklung über den LF | 17005/17006 → 19009/19010 | ORDERS/ORDRSP | `rechnungsabwicklung` | `E_0206`/`E_0209` |
24//! | Preisliste (PRICAT) | 27001–27003 | PRICAT | `preisliste` | — |
25//! | ESA Wertebestellung | 35003, 15003, 17007/17008, 39002, 19011–19014 | REQOTE/QUOTES/ORDERS/ORDCHG/ORDRSP | `wertebestellung`, `esa_wertebestellung` | — |
26//! | MSB-Rechnung (INVOIC) | 31009 → 33001/33003/33004, 29001 | INVOIC | `invoic` | — |
27//! | INSRPT Störungsmeldung | 23001 → 23003/23004/23008/23011/23012 | INSRPT | `insrpt` | — |
28//!
29//! ## Architecture
30//!
31//! Each BDEW process variant is a separate [`mako_engine::workflow::Workflow`]
32//! implementation. This crate contains **only pure domain logic** — no I/O,
33//! no EDIFACT parsing, no network calls.
34//!
35//! Parsing and validation of raw EDIFACT bytes must happen at the transport
36//! boundary (AS4 reception layer), **before** constructing a domain command.
37//! The workflow `handle()` function receives pre-extracted domain values:
38//!
39//! ```text
40//! AS4 transport layer
41//!   └── parse raw bytes          (edi-energy)
42//!       └── validate             (edi-energy)
43//!           └── extract fields   (application code)
44//!               └── DeviceChangeCommand { pid, melo_id, device_id, … }
45//!                   └── Process::execute(cmd)  ← pure domain logic here
46//! ```
47//!
48//! ## Three clocks, three messages
49//!
50//! | Clock | Window | Message |
51//! |---|---|---|
52//! | **APERAK** — processability | 45 min for Strom UTILMD/ORDERS (APERAK AHB 1.0 §2.4.1) | APERAK BGM+312/313 |
53//! | **Antwortfrist** — the business decision | 3 / 5 / 7 / 1 Werktage per process, from `antwort_frist_werktage(pid)` | the Antwort-PID's UTILMD or ORDRSP |
54//! | **Vorlauffrist** — was the requested date admissible? | anchored on the date the message carries, `mako_fristen::vorlauf` | the inbound message itself |
55//!
56//! Only the second discharges the Antwortfrist; the APERAK decides nothing.
57//! Where GPKE states its answer windows as clock times on the first Werktag
58//! after the ÜT, WiM states Werktage — see
59//! [`mako_fristen::antwort::GPKE_IS_NOT_TWENTY_FOUR_HOURS`].
60//!
61//! ## Command construction example
62//!
63//! ```rust,ignore
64//! use edi_energy::{AnyMessage, EdiEnergyMessage, Platform};
65//! use mako_wim::geraetewechsel::{WimDeviceChangeWorkflow, DeviceChangeCommand};
66//!
67//! let msg    = Platform::with_all_profiles().parse(&raw_bytes)?;
68//! let report = msg.validate()?;
69//! let AnyMessage::Utilmd(u) = &msg else { anyhow::bail!("not UTILMD") };
70//!
71//! let cmd = DeviceChangeCommand::ReceiveUtilmd {
72//!     pid:               msg.detect_pruefidentifikator()?,
73//!     sender:            u.sender().and_then(|n| n.party_id.clone()).unwrap_or_default(),
74//!     receiver:          u.receiver().and_then(|n| n.party_id.clone()).unwrap_or_default(),
75//!     melo_id:           u.transactions().first()
76//!                         .and_then(|t| t.marktlokation()).unwrap_or_default(),
77//!     device_id:         u.transactions().first()
78//!                         .and_then(|t| t.device_id().cloned()).unwrap_or_default(),
79//!     document_date:     u.dtm().iter().find(|d| d.is_document_date())
80//!                         .and_then(|d| d.value.clone()).unwrap_or_default(),
81//!     message_ref:       msg.message_ref().to_owned(),
82//!     validation_passed: report.is_valid(),
83//!     validation_errors: report.errors().iter()
84//!                         .map(|i| format!("{i}")).collect(),
85//! };
86//!
87//! process.execute(cmd).await?;
88//! ```
89
90#![deny(unsafe_code)]
91#![deny(missing_docs)]
92#![warn(clippy::pedantic, clippy::must_use_candidate)]
93#![allow(clippy::module_name_repetitions)]
94#![allow(clippy::doc_markdown)] // German MaKo terms and BDEW acronyms produce many false positives
95#![allow(clippy::too_many_lines)] // process handle() functions are necessarily verbose
96#![allow(clippy::match_same_arms)] // sometimes intentional for process-family readability
97#![allow(clippy::manual_let_else)] // existing code style; rewrite in follow-up
98#![allow(clippy::redundant_closure_for_method_calls)]
99#![allow(clippy::unnested_or_patterns)]
100#![allow(clippy::map_unwrap_or)]
101#![allow(clippy::items_after_statements)]
102
103pub mod consent;
104pub mod ersteinbau;
105pub mod esa;
106pub mod esa_wertebestellung;
107pub mod geraeteubernahme;
108pub mod geraetewechsel;
109pub mod insrpt;
110pub mod invoic;
111pub mod preisanfrage;
112pub mod preisliste;
113pub mod rechnungsabwicklung;
114pub mod stammdaten;
115pub mod steuerungsauftrag;
116pub mod technik_aenderung;
117pub mod weiterverpflichtung;
118pub mod wertebestellung;
119
120pub use geraeteubernahme::{
121    ANKUENDIGUNG_PIDS as GERAETEUBERNAHME_ANKUENDIGUNG_PIDS, BESTELLUNG_PIDS,
122    GERAETEUBERNAHME_PIDS, GeraeteubernahmeCommand, GeraeteubernahmeData, GeraeteubernahmeEvent,
123    GeraeteubernahmeProjection, GeraeteubernahmeRecord, GeraeteubernahmeRecordData,
124    GeraeteubernahmeState, ORDRSP_DEADLINE_LABEL as GERAETEUBERNAHME_ORDRSP_DEADLINE_LABEL,
125    WORKFLOW_NAME as GERAETEUBERNAHME_WORKFLOW_NAME, WimGeraeteubernahmeWorkflow,
126};
127pub use geraetewechsel::{
128    ANTWORT_FRIST_WINDOW_LABEL as GERAETEWECHSEL_ANTWORT_FRIST_WINDOW_LABEL,
129    AUFTRAG_ANTWORT_WINDOW_LABEL, DEVICE_CHANGE_ANTWORT_PIDS, DEVICE_CHANGE_PIDS,
130    DeviceChangeCommand, DeviceChangeData, DeviceChangeEvent, DeviceChangeProjection,
131    DeviceChangeRecord, DeviceChangeState, WORKFLOW_NAME, WimDeviceChangeWorkflow,
132    antwort_frist_werktage, antwort_pid_meaning,
133};
134pub use insrpt::{
135    ANTWORT_WINDOW_LABEL as INSRPT_ANTWORT_WINDOW_LABEL,
136    ERGEBNIS_WINDOW_LABEL as INSRPT_ERGEBNIS_WINDOW_LABEL, INSRPT_ANFRAGE_PIDS,
137    INSRPT_ANTWORT_PIDS, INSRPT_ERGEBNIS_PID, INSRPT_INFORMATIONS_PIDS, Seite as InsrptSeite,
138    StoerungsmeldungCommand, StoerungsmeldungData, StoerungsmeldungEvent, StoerungsmeldungState,
139    WEITERLEITUNG_WINDOW_LABEL as INSRPT_WEITERLEITUNG_WINDOW_LABEL,
140    WORKFLOW_NAME as INSRPT_WORKFLOW_NAME, WimInsrptWorkflow,
141};
142pub use invoic::{
143    GasAblehnung, SETTLEMENT_WINDOW_LABEL as INVOIC_SETTLEMENT_WINDOW_LABEL,
144    WIM_COMDIS_ABLEHNUNG_PID, WIM_INVOIC_PIDS, WIM_REMADV_PIDS,
145    WORKFLOW_NAME as INVOIC_WORKFLOW_NAME, WimInvoic, WimInvoicWorkflow, gas_ablehnungs_ebd,
146};
147pub use preisanfrage::{
148    PREISANFRAGE_DEADLINE_LABEL, PreisanfrageCommand, PreisanfrageData, PreisanfrageEvent,
149    PreisanfrageState, QUOTES_PIDS, REQOTE_PIDS, WORKFLOW_NAME as PREISANFRAGE_WORKFLOW_NAME,
150    WimPreisanfrageWorkflow, antwort_frist_werktage as preisanfrage_antwort_frist_werktage,
151};
152pub use preisliste::{
153    PRICAT_PIDS, PreislisteCommand, PreislisteData, PreislisteEvent, PreislisteState,
154    WORKFLOW_NAME as PREISLISTE_WORKFLOW_NAME, WimPreislisteWorkflow,
155};
156pub use rechnungsabwicklung::{
157    RECHNUNGSABWICKLUNG_DEADLINE_LABEL, RECHNUNGSABWICKLUNG_ORDERS_PIDS,
158    RECHNUNGSABWICKLUNG_ORDRSP_PIDS, RechnungsabwicklungCommand, RechnungsabwicklungData,
159    RechnungsabwicklungEvent, RechnungsabwicklungState,
160    WORKFLOW_NAME as RECHNUNGSABWICKLUNG_WORKFLOW_NAME, WimRechnungsabwicklungWorkflow,
161};
162pub use stammdaten::{
163    ANFORDERUNG_PID, STAMMDATEN_DEADLINE_LABEL, StammdatenCommand, StammdatenData, StammdatenEvent,
164    StammdatenProjection, StammdatenRecord, StammdatenRecordData, StammdatenState,
165    UEBERMITTLUNG_PIDS, WORKFLOW_NAME as STAMMDATEN_WORKFLOW_NAME, WimStammdatenWorkflow,
166};
167pub use steuerungsauftrag::{
168    STEUERUNGSAUFTRAG_DEADLINE_LABEL, SteuerungsCommandType, SteuerungsauftragCommand,
169    SteuerungsauftragData, SteuerungsauftragEvent, SteuerungsauftragState,
170    WORKFLOW_NAME as STEUERUNGSAUFTRAG_WORKFLOW_NAME, WimSteuerungsauftragWorkflow,
171};
172pub use technik_aenderung::{
173    AuftragData as TechnikAenderungAuftragData, ORDERS_PIDS as TECHNIK_AENDERUNG_ORDERS_PIDS,
174    ORDRSP_PIDS as TECHNIK_AENDERUNG_ORDRSP_PIDS, TechnikAenderungCommand, TechnikAenderungEvent,
175    TechnikAenderungState, WORKFLOW_NAME as TECHNIK_AENDERUNG_WORKFLOW_NAME,
176    WimTechnikAenderungWorkflow,
177};
178pub use weiterverpflichtung::{
179    ANTWORT_WINDOW_LABEL as WEITERVERPFLICHTUNG_ANTWORT_WINDOW_LABEL,
180    AUFTRAG_PID as WEITERVERPFLICHTUNG_AUFTRAG_PID, WEITERVERPFLICHTUNG_PIDS,
181    WORKFLOW_NAME as WEITERVERPFLICHTUNG_WORKFLOW_NAME, WeiterverpflichtungCommand,
182    WeiterverpflichtungData, WeiterverpflichtungEvent, WeiterverpflichtungProjection,
183    WeiterverpflichtungState, WimWeiterverpflichtungWorkflow,
184};
185
186// ── EngineModule ──────────────────────────────────────────────────────────────
187
188/// Engine module for the WiM process family.
189///
190/// Registers all WiM `Prüfidentifikator` values into the
191/// [`mako_engine::pid_router::PidRouter`] at engine startup:
192///
193/// | PID(s) | Workflow key | Module | Role |
194/// |---|---|---|---|
195/// | 55039 | `wim-device-change` | Kündigung MSB (MSBN → MSBA) | any |
196/// | 55042 | `wim-device-change` | Anmeldung MSB (MSBN → NB) | any |
197/// | 55051 | `wim-device-change` | Ende MSB / Abmeldung (MSBA → NB) | any |
198/// | 55168 | `wim-device-change` | Verpflichtungsanfrage / Aufforderung (NB → gMSB) | any |
199/// | 17001, 17002, 17009 | `wim-geraeteubernahme` | Geräteübernahme ORDERS | any |
200/// | 17132 | `wim-stammdaten` | Stammdaten Anforderung Strom (NB → MSB), MSB role | any |
201/// | 17102–17133 | `wim-stammdaten` | Stammdatenübermittlung responses (MSB → NB), NB role | **Nb only** |
202/// | 39002 | `wim-wertebestellung` | ESA Stornierung der Bestellung (ORDCHG) | **Msb only** |
203/// | 19001, 19002 | `wim-geraeteubernahme` | ORDRSP Bestellbestätigung/Ablehnung from NB | **nMSB only** |
204/// | 19015, 19016 | `wim-geraeteubernahme` | ORDRSP Gerätewechselabsicht Bestätigung/Ablehnung | any |
205///
206/// ## Role-conditional PIDs (ORDRSP 19001/19002)
207///
208/// GPKE Konfiguration claims 19001/19002 on an NB instance — it receives them
209/// after sending ORDERS 17134/17135 — and WiM Geräteübernahme claims them on an
210/// nMSB instance, answering its own ORDERS 17001. Only one reading can win, so
211/// the WiM one is registered when [`DeploymentRoles`] contains
212/// [`Marktrolle::Nmsb`]. Use [`DeploymentRoles::nmsb()`] and
213/// [`DeploymentRoles::nb()`] rather than a catch-all role set on a deployment
214/// that is both.
215///
216/// 19015/19016 are **not** gated: nothing else claims them, and the deployment
217/// that receives them is the one that sent the 17009 they answer.
218///
219/// [`DeploymentRoles`]: mako_engine::marktrolle::DeploymentRoles
220/// [`Marktrolle::Nmsb`]: mako_engine::marktrolle::Marktrolle::Nmsb
221/// [`DeploymentRoles::nmsb()`]: mako_engine::marktrolle::DeploymentRoles::nmsb
222/// [`DeploymentRoles::nb()`]: mako_engine::marktrolle::DeploymentRoles::nb
223pub struct WimModule;
224
225impl mako_engine::builder::EngineModule for WimModule {
226    fn name(&self) -> &'static str {
227        "wim"
228    }
229
230    fn workflow_names(&self) -> &'static [&'static str] {
231        // Every entry is the owning module's own constant. A literal here can
232        // disagree with the name `register_pids` routes to, and the two are
233        // checked against each other only at `EngineBuilder::build`.
234        &[
235            geraetewechsel::WORKFLOW_NAME,
236            geraeteubernahme::WORKFLOW_NAME,
237            ersteinbau::WORKFLOW_NAME,
238            stammdaten::WORKFLOW_NAME,
239            wertebestellung::WORKFLOW_NAME,
240            esa_wertebestellung::WORKFLOW_NAME,
241            steuerungsauftrag::WORKFLOW_NAME,
242            preisanfrage::WORKFLOW_NAME,
243            preisliste::WORKFLOW_NAME,
244            rechnungsabwicklung::WORKFLOW_NAME,
245            weiterverpflichtung::WORKFLOW_NAME,
246            invoic::WORKFLOW_NAME,
247            insrpt::WORKFLOW_NAME,
248            technik_aenderung::WORKFLOW_NAME,
249        ]
250    }
251
252    fn register_pids_with_roles(
253        &self,
254        router: &mut mako_engine::pid_router::PidRouter,
255        roles: &mako_engine::marktrolle::DeploymentRoles,
256    ) {
257        // UTILMD WiM MSB-Wechsel family (PIDs 55039, 55042, 55051, 55168).
258        //
259        // 55039 — Kündigung MSB (MSBN → MSBA): contract layer between the two MSB;
260        //         non-constitutive per BK6-24-174 WiM Teil 1 Kap. 2.1.3 — the NB is not a party.
261        // 55042 — Anmeldung MSB (MSBN → NB): new MSB initiates change.
262        // 55051 — Ende MSB / Abmeldung (MSBA → NB): NB terminates MSB relationship.
263        // 55168 — Verpflichtungsanfrage / Aufforderung (NB → gMSB).
264        //
265        // The Gas twins 44039 / 44042 / 44051 / 44168 (AWH WiM Gas 2.0) run the
266        // **same** Use-Cases with the same Fristen and are handled by the same
267        // workflow; `wim_sparte` reads the Sparte off the PID and it decides the
268        // Entscheidungsbaum, the Codeliste, the APERAK regime and the
269        // Zuordnungszeitpunkt (06:00 Uhr Gastag against 00:00).
270        //
271        // All eight share WimDeviceChangeWorkflow; the PID is carried in the
272        // DeviceChangeData and available for business-logic branching.
273        for &pid in geraetewechsel::DEVICE_CHANGE_PIDS {
274            router.register(pid, "wim-device-change");
275        }
276
277        // Antwort PIDs (Bestätigung / Ablehnung) for an order **we** sent.
278        // 55040/55041 ← 55039 · 55043/55044 ← 55042
279        // 55052/55053 ← 55051 · 55169/55170 ← 55168
280        //
281        // These resume the existing process by MeLo rather than spawning: the
282        // ingest dispatcher uses `resume_by_malo`, so an answer with no open
283        // order is skipped rather than creating an orphan stream.
284        for &(antwort_pid, _, _) in geraetewechsel::DEVICE_CHANGE_ANTWORT_PIDS {
285            router.register(antwort_pid, "wim-device-change");
286        }
287
288        // ORDERS 17002 → ORDRSP 19003/19004 — Weiterverpflichtung des MSB
289        // (WiM Teil 1 Kap. 2.4.2 Nr. 5/6, `E_0203`).
290        //
291        // Only the inbound leg is registered. 19003/19004 are *our* answer,
292        // rendered from the outbox — the NB-side receiver (mako sending 17002
293        // and awaiting the MSBA's ORDRSP) is not implemented, and registering
294        // an outbound-only PID would claim a dispatch arm that can never fire.
295        router.register(
296            weiterverpflichtung::AUFTRAG_PID,
297            weiterverpflichtung::WORKFLOW_NAME,
298        );
299
300        // ORDERS 17001/17009 — Geräteübernahme Bestellung and Anzeige
301        // Gerätewechselabsicht.
302        //
303        // **One workflow for both Sparten.** ORDERS and ORDRSP are Sparte-neutral
304        // AHBs, so these PIDs carry the Strom *and* the Gas Use-Case; the Sparte
305        // is the recipient MP-ID's and travels in the command, where it picks
306        // the Entscheidungsbaum and the Codeliste.
307        for &pid in geraeteubernahme::GERAETEUBERNAHME_PIDS {
308            router.register(pid, geraeteubernahme::WORKFLOW_NAME);
309        }
310
311        // ORDRSP 19015/19016 — Bestätigung/Ablehnung der Gerätewechselabsicht,
312        // the answer to the ORDERS 17009 the MSBN sends. No other module claims
313        // them, so they are registered unconditionally: gating them would
314        // dead-letter the answer to a message this deployment itself sent.
315        for pid in [19_015_u32, 19_016] {
316            router.register(pid, geraeteubernahme::WORKFLOW_NAME);
317        }
318
319        // ORDRSP 19001/19002 — Bestellbestätigung/Ablehnung, the answer to
320        // ORDERS 17001. GPKE Konfiguration claims the same two PIDs on an NB
321        // instance, so the MSBN reading is registered only when the Nmsb role is
322        // declared; `register_with_module` then panics at build() rather than
323        // silently letting one module overwrite the other.
324        if !roles.is_all() && roles.contains(mako_engine::marktrolle::Marktrolle::Nmsb) {
325            for pid in [19_001_u32, 19_002] {
326                router.register_with_module(pid, "wim-geraeteubernahme", "wim");
327            }
328        }
329
330        // ORDERS 17132 — Stammdaten Anforderung Strom (NB → MSB).
331        //
332        // When makod acts as MSB it receives this inbound (NB sends the request).
333        // When makod acts as NB it sends this outbound via the outbox; the MSB responds
334        // with one of the UEBERMITTLUNG_PIDS (17102–17133) which the NB then receives
335        // inbound — those are registered below under the Nb role guard.
336        //
337        // Note: 17101 („Anfrage zur Übermittlung von Stammdaten Gas") is the Gas
338        // counterpart. It is a GeLi Gas Geschäftsdatenanfrage, not a WiM
339        // Stammdatenanforderung, and is not routed here.
340        router.register(stammdaten::ANFORDERUNG_PID.as_u32(), "wim-stammdaten");
341
342        // Nb role: inbound Stammdatenübermittlung responses (MSB → NB).
343        //
344        // When makod acts as NB it sends ORDERS 17132 outbound and receives the MSB's
345        // response (one of PIDs 17102–17133) inbound. These are registered only for
346        // explicit Nb deployments to avoid routing conflicts on MSB-only instances.
347        //
348        // PIDs 17134/17135 are excluded: they are GPKE Konfiguration PIDs owned by
349        // mako-gpke and must not be claimed by the WiM Stammdaten module.
350        //
351        // PIDs 17115–17117 are excluded: GPKE/AWH Sperrprozesse ORDERS PIDs
352        // (Sperrauftrag / Aufhebung Sperrauftrag / Sperrung nicht möglich) owned by
353        // mako-gpke as "gpke-sperrung".
354        //
355        // The following GPKE-owned PIDs fall inside the 17102–17133 range and must
356        // not be claimed by wim-stammdaten to avoid ownership conflicts on combined NB
357        // deployments (both GpkeModule and WimModule active):
358        //
359        //   17102 (gpke-datenabruf, Datenabruf Anfrage LF→NB)
360        //   17110 (gpke-allokationsliste, Anforderung Allokationsliste)
361        //   17113 (gpke-datenabruf, Weitere Datenabruf Anfrage)
362        //   17114 (gpke-allokationsliste, Abmeldung Allokationsliste)
363        //   17120 (gpke-konfiguration-aenderung, Bestellung Konfiguration LF→NB)
364        //   17121 (gpke-konfiguration-aenderung, Bestellung Konfiguration LF→NB)
365        //   17122 (gpke-konfiguration-aenderung, Bestellung Konfigurationsänderung)
366        //   17123 (gpke-konfiguration-aenderung, Stornierung Konfigurationsbestellung)
367        //   17128 (gpke-konfiguration-aenderung, Bestellung Konfiguration LF→MSB)
368        //   17129 (gpke-konfiguration-aenderung, Bestellung Konfiguration LF→MSB)
369        //   17130 (gpke-konfiguration-aenderung, Bestellung Konfigurationsänderung LF→MSB)
370        //   17131 (gpke-konfiguration-aenderung, Stornierung Konfigurationsbestellung LF→MSB)
371        //   17133 (gpke-konfiguration-aenderung, Bestellung Konfiguration Reklamation)
372        //
373        // Source: site/content/docs/regulatory/pid-reference.md (generated from BDEW xlsx PID 3.3 + PID 4.0).
374        #[rustfmt::skip]
375        const GPKE_OWNED_IN_RANGE: &[u32] = &[
376            17102, 17113,                        // gpke-datenabruf
377            17110, 17114,                        // gpke-allokationsliste
378            17120, 17121, 17122, 17123,          // gpke-konfiguration-aenderung (LF→NB)
379            17128, 17129, 17130, 17131, 17133,   // gpke-konfiguration-aenderung (LF→MSB)
380            // 17115, 17116, 17117 already excluded by the matches!() guard below
381        ];
382        if !roles.is_all() && roles.contains(mako_engine::marktrolle::Marktrolle::Nb) {
383            for pid in stammdaten::UEBERMITTLUNG_PIDS {
384                if matches!(pid, 17115..=17117) {
385                    // Sperrung PIDs — owned by mako-gpke (gpke-sperrung).
386                    continue;
387                }
388                if GPKE_OWNED_IN_RANGE.contains(&pid) {
389                    // GPKE-owned PIDs — must not be claimed by wim-stammdaten.
390                    continue;
391                }
392                router.register(pid, "wim-stammdaten");
393            }
394        }
395
396        // REQOTE 35001/35002/35004/35005 (Preisanfrage) and QUOTES 15001/15002/15004/15005 (Angebot).
397        for &pid in preisanfrage::REQOTE_PIDS
398            .iter()
399            .chain(preisanfrage::QUOTES_PIDS)
400        {
401            router.register(pid, "wim-preisanfrage");
402        }
403
404        // PRICAT 27001–27003 (Preisliste).
405        for &pid in preisliste::PRICAT_PIDS {
406            router.register(pid, "wim-preisliste");
407        }
408
409        // Rechnungsabwicklung MSB über LF (WiM Strom Teil 1): ORDERS 17005
410        // (Bestellung — the LF accepting the quote; nothing answers it) and
411        // 17006 (Beendigung, either direction), plus ORDRSP 19009/19010
412        // (Bestätigung/Ablehnung der Beendigung) resuming a Beendigung mako
413        // sent. Directions per BDEW PID overview 4.0 / AWH Aktivitätsdiagramme
414        // WiM V1.3 §§2.8–2.11 (EBDs E_0206/E_0209).
415        for &pid in rechnungsabwicklung::RECHNUNGSABWICKLUNG_ORDERS_PIDS
416            .iter()
417            .chain(rechnungsabwicklung::RECHNUNGSABWICKLUNG_ORDRSP_PIDS)
418        {
419            router.register(pid, rechnungsabwicklung::WORKFLOW_NAME);
420        }
421
422        // IFTSTA 21032 „Antwort auf das Angebot" — the *other* half of the
423        // Prozessschritt ORDERS 17005 answers. 17005 is the LF's acceptance
424        // and carries no code; 21032 is its refusal and carries `E_0205` resp.
425        // `E_0208` (PID-Übersicht 4.0 lfd. Nr. 30930/31020). Registering only
426        // 17005 records every yes and dead-letters every no.
427        router.register(
428            rechnungsabwicklung::RECHNUNGSABWICKLUNG_ABLEHNUNG_PID,
429            rechnungsabwicklung::WORKFLOW_NAME,
430        );
431
432        // ── ESA Wertebestellung (WiM Teil 2 Kap. 4) ───────────────────────
433        //
434        // The two sides register disjoint PIDs, so an integrated deployment can
435        // hold both roles without a routing conflict.
436        //
437        // MSB side: inbound ORDERS 17007 Bestellung (UC 4.1 Nr. 3), 17008
438        // Abbestellung (UC 4.3 Nr. 1) and ORDCHG 39002 Stornierung (UC 4.1 Nr. 5)
439        // — all resume the *same* subscription process. §34 Abs. 2 S. 2 Nr. 10
440        // MsbG makes serving an ESA a mandatory Zusatzleistung, so an MSB must be
441        // able to process the order that authorises delivery, the one that stops
442        // it, and the cancellation of a not-yet-delivered Bestellung. The answers
443        // (ORDRSP 19011/19012/19013/19014) are outbox entries. The Stornierung
444        // carries no LOC — it is correlated by the Bestellung's Belegnummer
445        // echoed in RFF+ON (see the makod ingest dispatcher).
446        if roles.contains(mako_engine::marktrolle::Marktrolle::Msb) {
447            // REQOTE 35003 opens the handshake. ESA-specific (REQOTE AHB 1.1
448            // §4.3), so it routes straight here — it is not part of the
449            // Preisanfrage REQOTE set.
450            router.register(
451                wertebestellung::ANFRAGE_PID.as_u32(),
452                wertebestellung::WORKFLOW_NAME,
453            );
454            router.register(
455                wertebestellung::BESTELLUNG_PID.as_u32(),
456                wertebestellung::WORKFLOW_NAME,
457            );
458            router.register(
459                wertebestellung::ABBESTELLUNG_PID.as_u32(),
460                wertebestellung::WORKFLOW_NAME,
461            );
462            router.register(
463                wertebestellung::STORNIERUNG_PID.as_u32(),
464                wertebestellung::WORKFLOW_NAME,
465            );
466        }
467
468        // ESA side: this deployment *is* the ESA and originates the order
469        // handshake (REQOTE 35003 / ORDERS 17007 / ORDCHG 39002 / ORDERS 17008).
470        // The MSB's answers (QUOTES 15003, ORDRSP 19011-19014) are inbound here
471        // and resume the esa-wertebestellung process. Registered only for a
472        // deployment that *is* an ESA — an ESA has no Zuordnung to a
473        // Marktlokation, so nothing else may claim these. The set is disjoint
474        // from the MSB inbound PIDs, so an integrated deployment holds both.
475        if roles.contains(mako_engine::marktrolle::Marktrolle::Esa) {
476            for &pid in esa_wertebestellung::ESA_INBOUND_PIDS {
477                router.register(pid.as_u32(), esa_wertebestellung::WORKFLOW_NAME);
478            }
479        }
480
481        // INVOIC — the WiM-Rechnung in both Sparten: 31009 (MSB → NB/LF/ESA,
482        // WiM Strom Teil 1 Kap. 3.6/4), 31003 (MSBA → NB und MSBA → MSBN, AWH
483        // WiM Gas 2.0 Kap. 4.7) and the Sparte-neutral Stornorechnung 31004.
484        //
485        // These PIDs are explicitly excluded from mako-gpke's GPKE_INVOIC_PIDS array.
486        // Without registration here, all inbound WiM-domain INVOIC messages would
487        // be silently dead-lettered and no CONTRL acknowledgement would be sent,
488        // violating the AS4 acknowledgement obligation (BDEW AS4-Profile §5).
489        //
490        // The WimInvoicWorkflow provides a complete state machine with Settle/Dispute
491        // commands. Automatic outbound REMADV generation on the auto-settlement
492        // deadline is not implemented; settlement is driven by an explicit command.
493        for &pid in invoic::WIM_INVOIC_PIDS {
494            router.register(pid, "wim-invoic");
495        }
496
497        // REMADV 33001–33002 — inbound payment advice for WiM billing (invoicer role).
498        //
499        // After the NB sends INVOIC 31009 (MSB-Rechnung), the payer (MSB) sends
500        // back a REMADV (33001 = Bestätigung, 33002 = Ablehnung). Without this
501        // registration, all REMADV messages for WiM billing are silently dropped.
502        //
503        // GPKE billing registers 33003/33004 (Strom Abweisung Kopf und Summe /
504        // Position — itemized rejections). Per REMADV AHB 1.0a, WiM Strom billing
505        // (incl. ESA→MSB) ALSO rejects with the itemized 33003/33004; today mako-wim
506        // registers only 33001/33002 and leans on GPKE's 33003/34 registration, so a
507        // WiM itemized rejection is not yet routed to `wim-invoic`
508        // "REMADV itemized rejections in WiM scope". The registrations coexist because
509        // the makod router disambiguates shared REMADV PIDs by conversation ID
510        // (invoice correlation), not by PID alone.
511        //
512        // Source: REMADV AHB 1.0a §3, WiM Strom Teil 1 (BK6-24-174).
513        for &pid in invoic::WIM_REMADV_PIDS {
514            router.register(pid, "wim-invoic");
515        }
516
517        // COMDIS 29001 — inbound Ablehnung REMADV (invoicer rejects payer's REMADV).
518        //
519        // Shared PID with GPKE billing. The router dispatches to the correct
520        // workflow instance via conversation ID correlation.
521        //
522        // Source: COMDIS AHB 1.0, WiM Strom Teil 1 (BK6-24-174 Anlage 2a).
523        //
524        // Sparte-qualified for the same reason GPKE is: 29001 is also a GaBi Gas
525        // PID, and only the recipient's Sparte separates the two families. Which
526        // of the two **Strom** billing workflows a cold 29001 reaches is decided
527        // by conversation-ID correlation, as it is today.
528        router.register(invoic::WIM_COMDIS_ABLEHNUNG_PID.as_u32(), "wim-invoic");
529        router.register_with_sparte(
530            invoic::WIM_COMDIS_ABLEHNUNG_PID.as_u32(),
531            mako_engine::types::Sparte::Strom,
532            "wim-invoic",
533        );
534
535        // UTILMD 44183 „Ende MSB von NB" — the Gas NB informing the MSB of a
536        // Stilllegung (AWH WiM Gas 2.0 Kap. 3.7). Informational: it carries no
537        // Status der Antwort and has no answer Prüfidentifikator, so it lands
538        // on the same `ReceiveInformation` path as the IFTSTA Statusmeldungen.
539        router.register(geraetewechsel::ENDE_MSB_VOM_NB_PID, "wim-device-change");
540
541        // IFTSTA WiM PIDs 21009–21018 (MSB-Wechsel status messages).
542        //
543        // These are Vollzugsmeldungen and process-status notifications that
544        // accompany the WiM UTILMD device-change process. All are routed to
545        // `wim-device-change` for correlation via conversation ID (CI tag).
546        for &pid in geraetewechsel::IFTSTA_PIDS {
547            router.register(pid, "wim-device-change");
548        }
549
550        // Ersteinbau eines iMS in eine bestehende Messlokation — WiM Strom
551        // Teil 1 Kap. 3.5 (IFTSTA 21029 → 21030/21031, `E_0233`).
552        //
553        // Registered unconditionally because both sides of it are MSB work and
554        // a deployment can hold either: the grundzuständiger MSB sends the
555        // Vorabinformation and receives the answer, the wettbewerblicher MSB
556        // receives it and owes one in three Werktagen. Nothing else claims
557        // 21029–21031 — the Anwendungsübersicht 4.0 publishes them under
558        // „WiM Strom Teil 1 / Ersteinbau" alone.
559        //
560        // Strom only: there is no iMS rollout obligation in Gas, so AWH WiM Gas
561        // 2.0 has no Kap. 3.5 equivalent.
562        for &pid in ersteinbau::ERSTEINBAU_PIDS {
563            router.register(pid, ersteinbau::WORKFLOW_NAME);
564        }
565
566        // `wim-steuerungsauftrag` is intentionally NOT registered here.
567        //
568        // The Steuerungsauftrag workflow is driven exclusively by the BDEW
569        // API-Webdienste Strom `controlMeasuresV1` REST channel (BDEW
570        // API-Guideline 1.0a). There is no EDIFACT message type for this
571        // workflow; it receives no inbound PID dispatch from the `PidRouter`.
572        // The REST adapter (`energy-api`) creates process commands directly.
573        // Do not add EDIFACT PID registrations for this workflow.
574
575        // INSRPT Störungsbehebung in der Messlokation — WiM Strom Teil 2 Kap. 1
576        // and AWH WiM Gas 2.0 Kap. 4.3.
577        //
578        // 23001 Störungsmeldung (LF/NB → MSB), 23003/23004 Antwort, 23008
579        // Ergebnisbericht, 23005/23009 the Gas Informationsmeldungen an den NB,
580        // 23011/23012 the Strom Weiterleitung an betroffene Marktlokationen.
581        //
582        // **One workflow for both Sparten.** The INSRPT AHB is Sparte-neutral;
583        // what differs is the Frist, and the Frist is not a function of the PID
584        // in either Sparte — Strom branches on the Messtechnik and the
585        // Spannungsebene, Gas states one flat number. Both live in
586        // `insrpt::antwort_werktage` / `insrpt::ergebnis_werktage`, which take
587        // the Sparte as an argument.
588        for &pid in insrpt::INSRPT_ANFRAGE_PIDS
589            .iter()
590            .chain(insrpt::INSRPT_ANTWORT_PIDS)
591        {
592            router.register(pid, insrpt::WORKFLOW_NAME);
593        }
594
595        // WiM Technikänderung — device/config change requests (ORDERS/ORDRSP).
596        //
597        // Covers LF→MSB Änderung der Technik (17011) and MSB→MSB Bestellung
598        // Konfigurationsänderung (17118). The ESA order PIDs (17007/17008,
599        // ORDRSP 19011–19014) belong to `wertebestellung`.
600        // ORDRSP: Bestätigung (19003/19005) and Ablehnung (19004/19006/19007).
601        for &pid in technik_aenderung::ORDERS_PIDS {
602            router.register(pid, technik_aenderung::WORKFLOW_NAME);
603        }
604        for &pid in technik_aenderung::ORDRSP_PIDS {
605            router.register(pid, technik_aenderung::WORKFLOW_NAME);
606        }
607    }
608
609    fn profile_requirements(&self) -> &'static [mako_engine::profile::ProfileRequirement] {
610        use mako_engine::profile::ProfileRequirement;
611        &[
612            ProfileRequirement {
613                message_type: "UTILMD",
614                label: "UTILMD Strom (WiM Gerätewechsel)",
615            },
616            ProfileRequirement {
617                message_type: "APERAK",
618                label: "APERAK (WiM)",
619            },
620            ProfileRequirement {
621                message_type: "ORDERS",
622                label: "ORDERS (WiM Geräteübernahme/Stammdaten)",
623            },
624            ProfileRequirement {
625                message_type: "ORDRSP",
626                label: "ORDRSP (WiM Geräteübernahme Bestätigung 19001/19002/19015/19016)",
627            },
628            ProfileRequirement {
629                message_type: "ORDCHG",
630                label: "ORDCHG (WiM Stornierung)",
631            },
632            ProfileRequirement {
633                message_type: "IFTSTA",
634                label: "IFTSTA (WiM MSB-Wechsel 21007/21009–21013/21018/21036, \
635                        Ersteinbau iMS 21029–21031, Durchführungsmeldung 21025/21027)",
636            },
637            ProfileRequirement {
638                message_type: "INVOIC",
639                label: "INVOIC MSB-Rechnung (31009)",
640            },
641            ProfileRequirement {
642                message_type: "REMADV",
643                label: "REMADV Zahlungsavis (WiM 33001/33002)",
644            },
645            ProfileRequirement {
646                message_type: "COMDIS",
647                label: "COMDIS Ablehnung REMADV (WiM 29001)",
648            },
649            ProfileRequirement {
650                message_type: "INSRPT",
651                label: "INSRPT Störungsmeldung (WiM Strom/Gas, 23001–23012)",
652            },
653        ]
654    }
655
656    fn configure(&self) -> Result<(), String> {
657        // Verify that all static PID slices referenced by register_pids_with_roles()
658        // are non-empty. An accidental empty const (e.g. from a codegen regression)
659        // would silently mean the module registers no routes for an entire workflow
660        // family, discoverable only on first inbound message.
661        let named: &[(&str, &[u32])] = &[
662            (
663                "geraeteubernahme::BESTELLUNG_PIDS",
664                geraeteubernahme::BESTELLUNG_PIDS,
665            ),
666            (
667                "geraeteubernahme::ANKUENDIGUNG_PIDS",
668                geraeteubernahme::ANKUENDIGUNG_PIDS,
669            ),
670            ("geraetewechsel::IFTSTA_PIDS", geraetewechsel::IFTSTA_PIDS),
671            ("invoic::WIM_INVOIC_PIDS", invoic::WIM_INVOIC_PIDS),
672            ("invoic::WIM_REMADV_PIDS", invoic::WIM_REMADV_PIDS),
673            ("insrpt::INSRPT_ANFRAGE_PIDS", insrpt::INSRPT_ANFRAGE_PIDS),
674            ("insrpt::INSRPT_ANTWORT_PIDS", insrpt::INSRPT_ANTWORT_PIDS),
675            (
676                "technik_aenderung::ORDERS_PIDS",
677                technik_aenderung::ORDERS_PIDS,
678            ),
679            (
680                "technik_aenderung::ORDRSP_PIDS",
681                technik_aenderung::ORDRSP_PIDS,
682            ),
683        ];
684        for (name, pids) in named {
685            if pids.is_empty() {
686                return Err(format!(
687                    "wim: PID slice '{name}' is empty — \
688                     at least one PID must be registered for each workflow group",
689                ));
690            }
691        }
692        // UEBERMITTLUNG_PIDS is a RangeInclusive<u32>, not a slice; verify it is non-empty.
693        if stammdaten::UEBERMITTLUNG_PIDS.is_empty() {
694            return Err("wim: stammdaten::UEBERMITTLUNG_PIDS is empty — \
695                 at least one PID must be registered for the Stammdaten workflow"
696                .to_owned());
697        }
698        Ok(())
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use mako_engine::{
706        builder::EngineModule,
707        marktrolle::{DeploymentRoles, Marktrolle},
708        pid_router::PidRouter,
709    };
710
711    /// Regression test for the NB-role PID conflict between WiM Stammdaten
712    /// UEBERMITTLUNG_PIDS (17102..=17133) and GPKE-owned PIDs in that range.
713    ///
714    /// A bare `!roles.is_all() && roles.contains(Nb)` has WiM register the
715    /// GPKE-owned PIDs in that range to "wim-stammdaten", overwriting GPKE's
716    /// entries and silently misrouting the messages.
717    #[test]
718    fn nb_role_sperrung_not_overwritten_by_stammdaten_range() {
719        let nb = DeploymentRoles::from_roles([Marktrolle::Nb]);
720        let mut router = PidRouter::new();
721        // Simulate GPKE registration first (as it happens in makod startup order).
722        router.register(17115, "gpke-sperrung");
723        router.register(17116, "gpke-sperrung");
724        router.register(17117, "gpke-sperrung");
725        // GPKE-owned PIDs in the 17102..=17133 range
726        router.register(17102, "gpke-datenabruf");
727        router.register(17113, "gpke-datenabruf");
728        router.register(17110, "gpke-allokationsliste");
729        router.register(17114, "gpke-allokationsliste");
730        router.register(17120, "gpke-konfiguration-aenderung");
731        router.register(17121, "gpke-konfiguration-aenderung");
732        router.register(17122, "gpke-konfiguration-aenderung");
733        router.register(17123, "gpke-konfiguration-aenderung");
734        router.register(17128, "gpke-konfiguration-aenderung");
735        router.register(17129, "gpke-konfiguration-aenderung");
736        router.register(17130, "gpke-konfiguration-aenderung");
737        router.register(17131, "gpke-konfiguration-aenderung");
738        router.register(17133, "gpke-konfiguration-aenderung");
739
740        // WiM registration must NOT overwrite GPKE entries.
741        WimModule.register_pids_with_roles(&mut router, &nb);
742
743        // Sperrung PIDs must still route to gpke-sperrung, not wim-stammdaten.
744        assert_eq!(
745            router.route(17115),
746            Some("gpke-sperrung"),
747            "17115 must route to gpke-sperrung"
748        );
749        assert_eq!(
750            router.route(17116),
751            Some("gpke-sperrung"),
752            "17116 must route to gpke-sperrung"
753        );
754        assert_eq!(
755            router.route(17117),
756            Some("gpke-sperrung"),
757            "17117 must route to gpke-sperrung"
758        );
759
760        // GPKE-owned PIDs in range must not be overwritten by wim-stammdaten.
761        assert_eq!(
762            router.route(17102),
763            Some("gpke-datenabruf"),
764            "17102 must route to gpke-datenabruf"
765        );
766        assert_eq!(
767            router.route(17113),
768            Some("gpke-datenabruf"),
769            "17113 must route to gpke-datenabruf"
770        );
771        assert_eq!(
772            router.route(17110),
773            Some("gpke-allokationsliste"),
774            "17110 must route to gpke-allokationsliste"
775        );
776        assert_eq!(
777            router.route(17114),
778            Some("gpke-allokationsliste"),
779            "17114 must route to gpke-allokationsliste"
780        );
781        assert_eq!(
782            router.route(17120),
783            Some("gpke-konfiguration-aenderung"),
784            "17120 must route to gpke-konfiguration-aenderung"
785        );
786        assert_eq!(
787            router.route(17122),
788            Some("gpke-konfiguration-aenderung"),
789            "17122 must route to gpke-konfiguration-aenderung"
790        );
791        assert_eq!(
792            router.route(17128),
793            Some("gpke-konfiguration-aenderung"),
794            "17128 must route to gpke-konfiguration-aenderung"
795        );
796        assert_eq!(
797            router.route(17133),
798            Some("gpke-konfiguration-aenderung"),
799            "17133 must route to gpke-konfiguration-aenderung"
800        );
801
802        // True WiM Stammdaten PIDs in the range must still resolve to wim-stammdaten.
803        assert_eq!(
804            router.route(17132),
805            Some("wim-stammdaten"),
806            "17132 (ANFORDERUNG_PID) must route to wim-stammdaten"
807        );
808        // 17103 is a genuine wim-stammdaten PID (not GPKE-owned).
809        assert_eq!(
810            router.route(17103),
811            Some("wim-stammdaten"),
812            "17103 must route to wim-stammdaten"
813        );
814    }
815
816    /// Sanity: with DeploymentRoles::all() (default/dev), the NB gate does not
817    /// fire at all, so the UEBERMITTLUNG range is not registered and any prior
818    /// sperrung registration is undisturbed.
819    #[test]
820    fn all_roles_uebermittlung_gate_does_not_fire() {
821        let all = DeploymentRoles::all();
822        let mut router = PidRouter::new();
823        router.register(17115, "gpke-sperrung");
824        router.register(17116, "gpke-sperrung");
825        router.register(17117, "gpke-sperrung");
826        WimModule.register_pids_with_roles(&mut router, &all);
827
828        assert_eq!(router.route(17115), Some("gpke-sperrung"));
829        assert_eq!(router.route(17116), Some("gpke-sperrung"));
830        assert_eq!(router.route(17117), Some("gpke-sperrung"));
831        // 17132 ANFORDERUNG_PID should also be registered by the non-role-gated path.
832        assert_eq!(router.route(17132), Some("wim-stammdaten"));
833    }
834}