mako_mabis/lib.rs
1//! `mako-mabis` — MaBiS, the German electricity balance-group settlement
2//! (BNetzA **BK6-24-174 Anlage 3**).
3//!
4//! Strom only: gas balances through GaBi Gas, on the Gastag and against a
5//! Marktgebiet.
6//!
7//! # Three shapes MaBiS does not share with the other process families
8//!
9//! ## There is no Prüfmitteilung deadline
10//!
11//! A Summenzeitreihe arrives and a Prüfmitteilung goes back, so it is natural
12//! to hang a response Frist off the arrival the way GPKE and WiM do. The
13//! Festlegung says otherwise twice: Kap. 9.8.2 Nr. 1 leaves the Frist cell
14//! **empty** and says the receiving party „kann" answer, and Kap. 13.8.2 — the
15//! section a 1-Werktag deadline is usually attributed to — defines no answer at
16//! all; its two rows are the **BIKO's own** dispatch dates.
17//!
18//! What bounds a Prüfmitteilung is the clearing window of Kap. 3.10 Tabelle 2,
19//! a date range on the Bilanzierungsmonat rather than a countdown from an
20//! arrival. See [`fristen`].
21//!
22//! ## A settlement is a sequence of versions
23//!
24//! Kap. 3.8.2: versions ascend „über die gesamte BKA". One MaBiS-Zählpunkt in
25//! one Bilanzierungsmonat receives a stream of them, each checked, corrected
26//! and superseded until the window closes. The version is the
27//! **Erstellungszeitpunkt** — 17 characters in IFTSTA `SG4 RFF+AUU` and MSCONS
28//! `SG6 DTM+293` — and it is the key both ends match on.
29//!
30//! ## 55062 / 55063 / 55064 are generic codes
31//!
32//! Eleven Summenzeitreihen share them and 55064 answers all of them out of
33//! twelve different Entscheidungsbäume; six owe an answer and five do not. The
34//! discriminator is `SG10 CCI+++ZB4` / `CAV` DE 7111 plus `SG10 CCI+6`
35//! ([`zeitreihen::zeitreihe_aus_cav`], [`zp_lifecycle::ZpSerie::from_wire`]).
36//!
37//! # Workflows
38//!
39//! | Workflow | PIDs |
40//! |---|---|
41//! | `mabis-billing` | MSCONS 13003 · 13020 · 13023; IFTSTA 21000–21005 |
42//! | `mabis-profile` | MSCONS 13010–13012; ORDERS 17211 |
43//! | `mabis-clearingliste` | UTILMD 55067 · 55069 · 55070 · 55073 |
44//! | `mabis-listenabgleich` | UTILMD 55065/55066 · 55195/55196 · 55201/55202 · 55223/55224 |
45//! | `mabis-zp-lifecycle` | UTILMD 55062–55064 · 55071/55072 · 55197–55214 · 55235–55237 |
46//! | `mabis-anforderung` | ORDERS 17201–17208 · 17210; ORDRSP 19204 |
47//!
48//! # The Kapitel-17 series expire on 30.09.2026
49//!
50//! BK6-23-241 Tenorziffer 5 repeals MaBiS Anlage 1 Kapitel 17 with the end of
51//! **30.09.2026**. Kap. 17.1 and 17.3 continue as the „Anlage zur BilAReM";
52//! Kap. **17.2** (Bilanzkreismonitoring, tägliche AAÜZ — PIDs 55197/55198) and
53//! Kap. **17.3.2.1** do not. [`zeitreihen::Familie::endet_am`] and
54//! [`zp_lifecycle::ZpSerie::endet_am`] carry the date.
55//!
56//! # Architecture
57//!
58//! Each BDEW process variant is a separate [`mako_engine::workflow::Workflow`].
59//! This crate contains **only pure domain logic** — no I/O, no EDIFACT parsing,
60//! no clock.
61//!
62//! # Example
63//!
64//! ```sh
65//! cargo run --example mabis_bilanzkreisabrechnung -p mako-mabis
66//! ```
67
68#![deny(unsafe_code)]
69#![deny(missing_docs)]
70#![warn(clippy::pedantic, clippy::must_use_candidate)]
71#![allow(clippy::module_name_repetitions)]
72#![allow(clippy::doc_markdown)] // German MaKo terms and BDEW acronyms produce many false positives
73#![allow(clippy::too_many_lines)] // process handle() functions are necessarily verbose
74#![allow(clippy::match_same_arms)] // sometimes intentional for process-family readability
75#![allow(clippy::manual_let_else)] // a `match` on a MaBiS wire code reads better than a let-else
76#![allow(clippy::redundant_closure_for_method_calls)]
77#![allow(clippy::unnested_or_patterns)]
78#![allow(clippy::map_unwrap_or)]
79#![allow(clippy::items_after_statements)]
80
81pub mod anforderung;
82pub mod bilanzkreisabrechnung;
83pub mod clearingliste;
84pub mod fristen;
85pub mod ids;
86pub mod listenabgleich;
87pub mod profile;
88pub mod summenzeitreihe;
89pub mod zeitreihen;
90pub mod zp_lifecycle;
91pub mod zp_register;
92
93pub use anforderung::{
94 ANFORDERUNG_PIDS, AbonnementVorgang, AnforderungCommand, AnforderungData, AnforderungEvent,
95 AnforderungKind, AnforderungState, MabisAnforderungWorkflow,
96 WORKFLOW_NAME as ANFORDERUNG_WORKFLOW_NAME,
97};
98pub use bilanzkreisabrechnung::{
99 AUSFALLARBEIT_PIDS, BillingCommand, BillingData, BillingEvent, BillingProjection,
100 BillingRecord, BillingState, Datenstatus, IFTSTA_ABWEISUNG_PID, IFTSTA_DATENSTATUS_PIDS,
101 IFTSTA_PIDS, IFTSTA_PRUEFMITTEILUNG_PIDS, InvalidSzrVersion, MabisBillingWorkflow,
102 Pruefergebnis, RFF_QUALIFIER_VERSION, STS_KATEGORIE_DATENSTATUS, SUMMENZEITREIHE_PID,
103 SzrVersion, VersionRecord, WORKFLOW_NAME as BILLING_WORKFLOW_NAME, ist_zeitreihen_pid,
104};
105pub use clearingliste::{
106 CLEARINGLISTE_PIDS, ClearinglisteCommand, ClearinglisteData, ClearinglisteEvent,
107 ClearinglisteKind, ClearinglisteState, MabisClearinglisteWorkflow,
108 WORKFLOW_NAME as CLEARINGLISTE_WORKFLOW_NAME,
109};
110pub use fristen::{
111 Abrechnungslauf, BIKO_DATENSTATUS_WERKTAGE, BIKO_WEITERLEITUNG_WERKTAGE, Bilanzierungsmonat,
112 CLEARING_ENDE_LABEL, Fenster, Phase, Stichtag,
113};
114pub use listenabgleich::{
115 LISTEN_FAMILIEN, ListenFamilie, ListenTyp, ListenabgleichCommand, ListenabgleichData,
116 ListenabgleichEvent, ListenabgleichState, MabisListenabgleichWorkflow,
117 WORKFLOW_NAME as LISTENABGLEICH_WORKFLOW_NAME, all_pids as listenabgleich_pids,
118};
119pub use zeitreihen::{
120 Aggregationsebene, Bezugszeitraum, CCI_BEZEICHNUNG_SUMMENZEITREIHE,
121 CCI_KLASSENTYP_VERANTWORTLICHER, Familie, KAPITEL_17_2_ENDE, Kategorie, Messtechnik, Rolle,
122 UnbekannteKategorie, Zeitreihe, aggregationsverantwortung, alle as alle_zeitreihen,
123 cav_aus_zeitreihe, cci_aus_rolle, rolle_aus_cci, zeitreihe_aus_cav,
124};
125// Canonical balance-group topology IDs (defined in `ids`).
126pub use anforderung::{ABLEHNUNG_PID, all_pids as anforderung_pids};
127pub use ids::{BilanzierungsgebietId, BilanzkreisId, InvalidMabisZaehlpunkt, MabisZaehlpunktId};
128pub use profile::{
129 Bilanzierungsverfahren, ERSTLIEFERUNG_WERKTAGE, MabisProfilWorkflow, PROFIL_PIDS,
130 ProfilCommand, ProfilData, ProfilEvent, ProfilState, Profilart, REKLAMATION_EBD,
131 REKLAMATION_PID, WORKFLOW_NAME as PROFIL_WORKFLOW_NAME, all_pids as profil_pids,
132};
133pub use summenzeitreihe::{
134 MABIS_SLOT, SlotResolutionError, SumInterval, Summenzeitreihe, SummenzeitreiheBuilder,
135};
136pub use zp_lifecycle::{
137 MabisZpLifecycleWorkflow, TAEGLICHE_AAUEZ_ENDE, WORKFLOW_NAME as ZP_LIFECYCLE_WORKFLOW_NAME,
138 ZP_FAMILIEN, ZpFamilie, ZpLifecycleCommand, ZpLifecycleData, ZpLifecycleEvent,
139 ZpLifecycleState, ZpSerie, ZpVorgang, all_pids as zp_lifecycle_pids,
140 antwort_ist_zustimmung as zp_antwort_ist_zustimmung, familie_for, ist_antwort_pid,
141 ist_weiterleitung_pid, serien_fuer_pid,
142};
143
144// ── EngineModule ──────────────────────────────────────────────────────────────
145
146/// Engine module for the MaBiS process family.
147///
148/// # PID ownership
149///
150/// | Workflow | PIDs |
151/// |---|---|
152/// | `mabis-billing` | MSCONS 13003 · 13020 · 13023; IFTSTA 21000–21005 |
153/// | `mabis-profile` | MSCONS 13010–13012; ORDERS 17211 |
154/// | `mabis-clearingliste` | UTILMD 55067 · 55069 · 55070 · 55073 |
155/// | `mabis-listenabgleich` | UTILMD 55065/55066 · 55195/55196 · 55201/55202 · 55223/55224 |
156/// | `mabis-zp-lifecycle` | UTILMD 55062–55064 · 55071/55072 · 55197–55214 · 55235–55237 |
157/// | `mabis-anforderung` | ORDERS 17201–17208 · 17210; ORDRSP 19204 |
158///
159/// Each workflow's module docs carry the use case it answers and the Fristen
160/// the Festlegung attaches to it.
161pub struct MabisModule;
162
163impl mako_engine::builder::EngineModule for MabisModule {
164 fn name(&self) -> &'static str {
165 "mabis"
166 }
167
168 fn workflow_names(&self) -> &'static [&'static str] {
169 // Every entry is the owning module's own constant. A literal here can
170 // disagree with the name `register_pids` routes to, and the two are
171 // checked against each other only at `EngineBuilder::build`.
172 &[
173 bilanzkreisabrechnung::WORKFLOW_NAME,
174 profile::WORKFLOW_NAME,
175 clearingliste::WORKFLOW_NAME,
176 zp_lifecycle::WORKFLOW_NAME,
177 anforderung::WORKFLOW_NAME,
178 listenabgleich::WORKFLOW_NAME,
179 ]
180 }
181
182 fn register_pids(&self, router: &mut mako_engine::pid_router::PidRouter) {
183 // ── MSCONS Summenzeitreihen ─────────────────────────────────────────
184 //
185 // 13003 „Summenzeitreihen und Ausfallarbeitssummen" (MSCONS AHB 3.1g §5)
186 // carries every BG-/BK-/LF-SZR, the DZÜ, the NZR and the
187 // Abrechnungssummenzeitreihe.
188 //
189 // 13020 Ausfallarbeitsüberführungszeitreihe and 13023
190 // Lieferantenausfallarbeitssummenzeitreihe are **MaBiS**, not
191 // Redispatch: the PID overview files both under the MaBiS
192 // Prozessbeschreibung and both carry the full Prüfmitteilung/
193 // Datenstatus cycle (IFTSTA 21000/21002–21005). They were routed to a
194 // Redispatch workflow, which had no settlement stream to put them in.
195 //
196 // 13022 stays with `mako-redispatch`: it is the TR-scharfe Einzel-
197 // zeitreihe the BTR and the NB reconcile, not a Summenzeitreihe.
198 // 13021 (meteorologische Ex-post-Daten) and 13026 (EEG-Überführungs-
199 // zeitreihe) are likewise not MaBiS.
200 //
201 // Confirmed absent: PID 13001 does not exist in any MSCONS AHB version.
202 router.register(bilanzkreisabrechnung::SUMMENZEITREIHE_PID, "mabis-billing");
203 for &pid in bilanzkreisabrechnung::AUSFALLARBEIT_PIDS {
204 router.register(pid, "mabis-billing");
205 }
206
207 // ── IFTSTA MaBiS Statusmeldungen 21000–21005 ────────────────────────
208 //
209 // All six route to `mabis-billing` so they correlate with their
210 // settlement stream by conversation ID. Their *direction* is not
211 // uniform: 21000/21001/21005 are this participant's own outbound
212 // Prüfmitteilungen, 21002 is the BIKO's Abweisung, and **both** 21003
213 // and 21004 carry a Datenstatus — 21003 to the NB/ÜNB, 21004 to the
214 // BKV. See `bilanzkreisabrechnung` for the table.
215 //
216 // PID 21006 does not exist. PID 21007 is WiM Strom Teil 1 / WiM Gas and
217 // is registered in `mako-wim` (`wim-device-change`).
218 for &pid in bilanzkreisabrechnung::IFTSTA_PIDS {
219 router.register(pid, "mabis-billing");
220 }
221
222 // ── Normierte Profile (Kap. 6.5 / 6.7) ──────────────────────────────
223 //
224 // MSCONS 13010/13011/13012 deliver the values; ORDERS 17211 is the LF's
225 // Reklamation (EBD E_0100). 17211 was filed with the Redispatch ORDERS
226 // codes, which left the delivery with no correction leg at all.
227 for pid in profile::all_pids() {
228 router.register(pid, profile::WORKFLOW_NAME);
229 }
230
231 // ── Record-only UTILMD lists ────────────────────────────────────────
232 //
233 // 55067 Bilanzkreiszuordnungsliste, 55069 Clearingliste DZR,
234 // 55070 Clearingliste BAS, 55073 Liste der Profildefinitionen.
235 //
236 // 55065 is deliberately **not** here: it owes a 55066 Korrekturliste and
237 // belongs to `mabis-listenabgleich`.
238 for &pid in clearingliste::CLEARINGLISTE_PIDS {
239 router.register(pid, clearingliste::WORKFLOW_NAME);
240 }
241
242 // ── UTILMD lists with a correction leg ──────────────────────────────
243 //
244 // 55065/55066, 55195/55196, 55201/55202, 55223/55224.
245 for pid in listenabgleich::all_pids() {
246 router.register(pid, listenabgleich::WORKFLOW_NAME);
247 }
248
249 // ── MaBiS-Zählpunkt lifecycle ───────────────────────────────────────
250 //
251 // The PID set comes from `zp_lifecycle::ZP_FAMILIEN`, so the router and
252 // the state machine cannot disagree about which codes exist. 55062,
253 // 55063 and 55064 are **generic**: eleven series share them and 55064 is
254 // answered out of twelve different EBDs, so the workflow is keyed on the
255 // series and not on the PID.
256 for pid in zp_lifecycle::all_pids() {
257 router.register(pid, zp_lifecycle::WORKFLOW_NAME);
258 }
259
260 // ── MaBiS Anforderungen ─────────────────────────────────────────────
261 //
262 // ORDERS 17201–17208 and 17210, plus the one Ablehnung the family has,
263 // ORDRSP 19204 (only 17207 can be refused). 17210 was filed with the
264 // Redispatch codes; it asks the ANB for the
265 // Lieferantenausfallarbeitsclearingliste, which is a MaBiS list.
266 for pid in anforderung::all_pids() {
267 router.register(pid, anforderung::WORKFLOW_NAME);
268 }
269 }
270
271 fn profile_requirements(&self) -> &'static [mako_engine::profile::ProfileRequirement] {
272 use mako_engine::profile::ProfileRequirement;
273 &[
274 ProfileRequirement {
275 message_type: "MSCONS",
276 label: "MSCONS Summenzeitreihen und Profile (MaBiS 13003, 13010–13012, 13020, 13023)",
277 },
278 ProfileRequirement {
279 message_type: "IFTSTA",
280 label: "IFTSTA Statusmeldung (MaBiS 21000–21005)",
281 },
282 ProfileRequirement {
283 message_type: "UTILMD",
284 label: "UTILMD MaBiS-Listen und ZP-Lifecycle (55062–55073, 55195–55224)",
285 },
286 ProfileRequirement {
287 message_type: "ORDERS",
288 label: "ORDERS MaBiS Anforderungen (17201–17208, 17210, 17211)",
289 },
290 ProfileRequirement {
291 message_type: "ORDRSP",
292 label: "ORDRSP Ablehnung Ab-/Bestellung der Aggregationsebene (19204)",
293 },
294 ]
295 }
296
297 fn configure(&self) -> Result<(), String> {
298 // No two workflows may claim the same PID: the router is last-write-wins,
299 // so a collision would silently route a message to whichever module
300 // registered second.
301 let mut seen: Vec<(u32, &'static str)> = Vec::new();
302 let mut push = |pids: Vec<u32>, wf: &'static str| -> Result<(), String> {
303 for pid in pids {
304 if let Some((_, other)) = seen.iter().find(|(p, _)| *p == pid) {
305 return Err(format!("PID {pid} claimed by both {other} and {wf}"));
306 }
307 seen.push((pid, wf));
308 }
309 Ok(())
310 };
311 let mut billing = vec![bilanzkreisabrechnung::SUMMENZEITREIHE_PID];
312 billing.extend_from_slice(bilanzkreisabrechnung::AUSFALLARBEIT_PIDS);
313 billing.extend_from_slice(bilanzkreisabrechnung::IFTSTA_PIDS);
314 push(billing, "mabis-billing")?;
315 push(profile::all_pids(), profile::WORKFLOW_NAME)?;
316 push(
317 clearingliste::CLEARINGLISTE_PIDS.to_vec(),
318 clearingliste::WORKFLOW_NAME,
319 )?;
320 push(listenabgleich::all_pids(), listenabgleich::WORKFLOW_NAME)?;
321 push(zp_lifecycle::all_pids(), zp_lifecycle::WORKFLOW_NAME)?;
322 push(anforderung::all_pids(), anforderung::WORKFLOW_NAME)?;
323 Ok(())
324 }
325}
326
327#[cfg(test)]
328mod module_tests {
329 use super::*;
330 use mako_engine::builder::EngineModule;
331
332 #[test]
333 fn no_two_workflows_claim_the_same_pid() {
334 MabisModule.configure().expect("PID ownership is disjoint");
335 }
336
337 #[test]
338 fn the_lieferantenclearingliste_is_not_record_only() {
339 assert!(!clearingliste::CLEARINGLISTE_PIDS.contains(&55065));
340 assert!(listenabgleich::all_pids().contains(&55065));
341 assert!(listenabgleich::all_pids().contains(&55066));
342 }
343
344 #[test]
345 fn the_mabis_ausfallarbeit_series_are_registered_here() {
346 // 13020 (AAÜZ) and 13023 (LF-AASZR) are MaBiS Summenzeitreihen with a
347 // full Prüfmitteilung/Datenstatus cycle, so they settle here; a
348 // Redispatch workflow has no settlement stream for them.
349 assert_eq!(bilanzkreisabrechnung::AUSFALLARBEIT_PIDS, &[13_020, 13_023]);
350 }
351
352 #[test]
353 fn the_redispatch_only_mscons_pids_stay_out() {
354 // 13021 meteorologische Daten, 13022 Einzelzeitreihe Ausfallarbeit,
355 // 13026 EEG-Überführungszeitreihe are not MaBiS.
356 let mut claimed = vec![bilanzkreisabrechnung::SUMMENZEITREIHE_PID];
357 claimed.extend_from_slice(bilanzkreisabrechnung::AUSFALLARBEIT_PIDS);
358 claimed.extend(profile::all_pids());
359 for pid in [13_021, 13_022, 13_026] {
360 assert!(!claimed.contains(&pid), "{pid} is not a MaBiS PID");
361 }
362 }
363
364 #[test]
365 fn the_hkn_register_ordrsp_codes_stay_out() {
366 // 19301/19302 belong to the Herkunftsnachweisregister exchange, not to
367 // MaBiS and not to Redispatch.
368 for pid in [19_301_u32, 19_302] {
369 assert!(!anforderung::all_pids().contains(&pid));
370 }
371 }
372
373 #[test]
374 fn every_workflow_name_is_prefixed() {
375 for name in MabisModule.workflow_names() {
376 assert!(name.starts_with("mabis-"), "{name}");
377 }
378 }
379}