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}