Skip to main content

mako_engine/
erc.rs

1//! BDEW ERC error codes — structured rejection codes for APERAK and CONTRL.
2//!
3//! BDEW ERC codes appear in:
4//! - **APERAK** `ERC` segments: processability errors returned by the receiving
5//!   party when it cannot process a message (BGM+313).
6//!
7//! `CONTRL` carries no `ERC`: it reports a syntax failure in `UCI`/`UCM`
8//! DE 0085, which is a different vocabulary and not this module's.
9//!
10//! This module provides a validated [`ErcCode`] newtype, a catalogue of
11//! standard code string constants in [`codes`], and [`ErcAction`] — a
12//! machine-readable recommended automated response for each code.
13//! Domain crates `match` on the ERC code to drive typed ERP automation
14//! instead of freeform text parsing.
15//!
16//! # Separation of concerns
17//!
18//! | Layer | Responsibility |
19//! |---|---|
20//! | `edi-energy` | Wire-format parsing; raw `String` from ERC segment |
21//! | `mako-engine::erc` | Validated type; constants; role-agnostic [`ErcAction`] recommendation |
22//! | Domain crates | Process-specific `match` on [`ErcCode`] → domain decision |
23//! | `makod` | [`ErcCode`] in outbox payload → `makoerc` CloudEvents extension |
24//!
25//! # Regulatory sources
26//!
27//! - APERAK MIG 2.1i / 2.2 — `SG4 ERC` C901 DE 9321, the code table
28//! - APERAK AHB 1.0 / 1.1 — which codes each Anwendungsfall admits, and the
29//!   Bedingungen that oblige the `SG5 FTX+Z02` Ortsangabe
30//! - Allgemeine Festlegungen V6.1d (01.04.2026) — §4 rejection handling
31//!
32//! # Example
33//!
34//! ```rust
35//! use mako_engine::erc::{ErcCode, ErcAction, codes, recommended_action};
36//!
37//! let code = ErcCode::new(codes::Z39);
38//! assert!(matches!(
39//!     recommended_action(&code),
40//!     ErcAction::RetryWithCorrection { field: "message" }
41//! ));
42//! ```
43
44use serde::{Deserialize, Serialize};
45
46// ── ErcCode ───────────────────────────────────────────────────────────────────
47
48/// A BDEW `ERC` DE 9321 error code from an inbound APERAK.
49///
50/// Wraps an arbitrary string.  Use [`codes`] for known BDEW constants.
51/// Use [`ErcCode::new`] for codes parsed from inbound EDIFACT that may not
52/// be in the known set (e.g. proprietary NB codes).
53///
54/// Implements `Serialize`/`Deserialize` as a transparent JSON string so it
55/// passes through CloudEvents payloads unchanged.
56#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(transparent)]
58pub struct ErcCode(Box<str>);
59
60impl ErcCode {
61    /// Wrap an arbitrary string as an ERC code.
62    ///
63    /// No validation is applied — malformed codes from counterparties are
64    /// accepted for forensic purposes and matched via `==` or
65    /// [`recommended_action`].
66    pub fn new(code: impl Into<Box<str>>) -> Self {
67        Self(code.into())
68    }
69
70    /// Return the code string (e.g. `"Z29"`).
71    #[must_use]
72    pub fn as_str(&self) -> &str {
73        &self.0
74    }
75}
76
77impl std::fmt::Display for ErcCode {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.write_str(&self.0)
80    }
81}
82
83impl AsRef<str> for ErcCode {
84    fn as_ref(&self) -> &str {
85        &self.0
86    }
87}
88
89impl From<&str> for ErcCode {
90    fn from(s: &str) -> Self {
91        Self::new(s)
92    }
93}
94
95// ── ErcAction ─────────────────────────────────────────────────────────────────
96
97/// Recommended automated response for a received ERC rejection code.
98///
99/// This is **advice**, not a hard rule.  The ERP decides whether to follow
100/// it based on local policy, retry budget, and operator escalation settings.
101///
102/// Source: BDEW APERAK AHB 1.0; CONTRL AHB 1.0; Allgemeine Festlegungen V6.1d §4.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum ErcAction {
105    /// Correct the named field and re-submit the process.
106    RetryWithCorrection {
107        /// Short identifier of the field to correct
108        /// (e.g. `"malo_id"`, `"address"`, `"process_date"`).
109        field: &'static str,
110    },
111    /// Escalate to an operator for manual investigation.
112    EscalateToOperator {
113        /// Brief reason string for the operator notification.
114        reason: &'static str,
115    },
116    /// Abort the process — the counterparty has definitively rejected it.
117    AbortProcess,
118    /// Wait for a conflicting in-flight process to finish, then retry.
119    WaitAndRetry {
120        /// Human-readable description of the blocking condition.
121        reason: &'static str,
122    },
123}
124
125// ── Standard BDEW ERC code string constants ───────────────────────────────────
126
127/// The `ERC` DE 9321 „Anwendungsfehler, Code" values, verbatim from the
128/// APERAK MIG 2.1i code table. There is no second vocabulary: `CONTRL`
129/// reports syntax failures in `UCI`/`UCM` DE 0085, not in an `ERC`, and a
130/// code outside this list is refused by the receiving Marktpartner's own
131/// Prüfschablone.
132///
133/// Which of them a given Anwendungsfall admits is narrower still and is
134/// decided by the profile, not here — APERAK AHB 1.0 admits 27 for 29001
135/// and none for 29002 (an Anerkennungsmeldung opens no `SG4`).
136///
137/// These are `&'static str` values so they can be used directly inside
138/// `serde_json::json!` macro expressions:
139///
140/// ```rust
141/// use mako_engine::erc::codes;
142///
143/// let payload = serde_json::json!({ "error_code": codes::Z29 });
144/// assert_eq!(payload["error_code"], "Z29");
145/// ```
146///
147/// Use [`ErcCode::new(codes::Z29)`][ErcCode::new] when a rich typed value is
148/// needed (e.g. for storing in workflow state or `ErpEventType::AperakRejected`).
149pub mod codes {
150
151    /// ID unbekannt.
152    pub const Z10: &str = "Z10";
153
154    /// Objekt im IT-System nicht gefunden.
155    pub const Z14: &str = "Z14";
156
157    /// Objekt im IT-System nicht eindeutig.
158    pub const Z15: &str = "Z15";
159
160    /// Objekt nicht mehr im Netzgebiet.
161    pub const Z16: &str = "Z16";
162
163    /// Absender ist zum angegebenen Zeitintervall / Zeitpunkt dem Objekt nicht
164    /// zugeordnet.
165    pub const Z17: &str = "Z17";
166
167    /// Empfänger ist zum angegebenen Zeitintervall / Zeitpunkt dem Objekt nicht
168    /// zugeordnet.
169    pub const Z18: &str = "Z18";
170
171    /// Gerätenummer zum angegebenen Zeitintervall / Zeitpunkt an der
172    /// Messlokation nicht bekannt.
173    pub const Z19: &str = "Z19";
174
175    /// OBIS-Kennzahl zum angegebenen Zeitintervall / Zeitpunkt am Objekt nicht
176    /// bekannt.
177    pub const Z20: &str = "Z20";
178
179    /// Geschäftsvorfallinterne Referenzierung fehlerhaft.
180    pub const Z21: &str = "Z21";
181
182    /// Zuordnungs-Tupel unbekannt.
183    pub const Z24: &str = "Z24";
184
185    /// Absender ist zum angegebenen Zeitintervall / Zeitpunkt dem durch das
186    /// Zuordnungs-Tupel identifizierten Objekt nicht zugeordnet.
187    pub const Z25: &str = "Z25";
188
189    /// Empfänger ist zum angegebenen Zeitintervall / Zeitpunkt dem durch das
190    /// Zuordnungs-Tupel identifizierten Objekt nicht zugeordnet.
191    pub const Z26: &str = "Z26";
192
193    /// Vorkomma-Stellenzahl des Zählwertes ist zu lang.
194    pub const Z27: &str = "Z27";
195
196    /// Erforderliche Angabe für diesen Anwendungsfall fehlt.
197    ///
198    /// The code for a message that does not satisfy its own Prüfschablone —
199    /// what `edi_energy` reports as an `AHB-…-MISSING` finding. Obliges an
200    /// `SG5 FTX+Z02` Ortsangabe; see
201    /// [`super::requires_ortsangabe`].
202    pub const Z29: &str = "Z29";
203
204    /// Zeitreihe unvollständig.
205    pub const Z30: &str = "Z30";
206
207    /// Geschäftsvorfall wird vom Empfänger zurückgewiesen.
208    ///
209    /// The business rejection: the message is well-formed and the receiver
210    /// declines it anyway.
211    pub const Z31: &str = "Z31";
212
213    /// Referenziertes Geschäftsvorfall-Tupel nicht vorhanden.
214    pub const Z33: &str = "Z33";
215
216    /// Zeitintervall negativ oder Null.
217    pub const Z34: &str = "Z34";
218
219    /// Format nicht eingehalten. Obliges an Ortsangabe.
220    pub const Z35: &str = "Z35";
221
222    /// Geschäftsvorfall darf vom Sender nicht gesendet werden.
223    pub const Z37: &str = "Z37";
224
225    /// Anzahl der übermittelten Codes überschreitet Paketdefinition. Obliges an
226    /// Ortsangabe.
227    pub const Z38: &str = "Z38";
228
229    /// Code nicht aus erlaubtem Wertebereich. Obliges an Ortsangabe.
230    pub const Z39: &str = "Z39";
231
232    /// Segment- bzw. Segmentgruppenwiederholbarkeit überschritten. Obliges an
233    /// Ortsangabe.
234    pub const Z40: &str = "Z40";
235
236    /// Zeitangabe unplausibel. Obliges an Ortsangabe.
237    pub const Z41: &str = "Z41";
238
239    /// Konfigurations-ID zum angegebenen Zeitintervall / Zeitpunkt nicht
240    /// bekannt.
241    pub const Z42: &str = "Z42";
242
243    /// Geschäftsvorfall für Objekt mit der Eigenschaft nicht erlaubt.
244    pub const Z43: &str = "Z43";
245
246    /// Eigenschaft des Objekts weicht von der im Geschäftsvorfall codierten
247    /// Eigenschaft ab.
248    pub const Z44: &str = "Z44";
249
250    /// Every code above, for exhaustiveness checks.
251    pub const ALL: [&str; 27] = [
252        Z10, Z14, Z15, Z16, Z17, Z18, Z19, Z20, Z21, Z24, Z25, Z26, Z27, Z29, Z30, Z31, Z33, Z34,
253        Z35, Z37, Z38, Z39, Z40, Z41, Z42, Z43, Z44,
254    ];
255}
256
257/// Whether an ERC code obliges the `SG5 FTX+Z02` Ortsangabe des AHB-Fehlers.
258///
259/// Re-exported from `edi_energy` so a workflow can decide without depending on
260/// the wire crate; the list itself is an APERAK AHB fact and lives there.
261#[must_use]
262pub fn requires_ortsangabe(code: &str) -> bool {
263    matches!(
264        code,
265        codes::Z29 | codes::Z35 | codes::Z38 | codes::Z39 | codes::Z40 | codes::Z41
266    )
267}
268
269// ── recommended_action ────────────────────────────────────────────────────────
270
271/// Return the recommended automated ERP action for a received ERC code.
272///
273/// The codes are DE 9321's; an unlisted or proprietary one defaults to
274/// [`ErcAction::EscalateToOperator`] so nothing is silently swallowed.
275///
276/// This is **advice**. What the sender can actually do follows from what the
277/// code says is wrong: a code naming a field it can correct is a retry, a code
278/// saying the receiver declines is not, and a code about an ID or an assignment
279/// it believes to be right is a question for an operator.
280///
281/// # Example
282///
283/// ```rust
284/// use mako_engine::erc::{ErcCode, ErcAction, codes, recommended_action};
285///
286/// let code = ErcCode::new(codes::Z31);
287/// assert_eq!(recommended_action(&code), ErcAction::AbortProcess);
288/// ```
289#[must_use]
290pub fn recommended_action(code: &ErcCode) -> ErcAction {
291    match code.as_str() {
292        // The message itself is wrong and the sender can correct it: the code
293        // names the segment, the format or the value that failed.
294        codes::Z29 | codes::Z35 | codes::Z38 | codes::Z39 | codes::Z40 => {
295            ErcAction::RetryWithCorrection { field: "message" }
296        }
297        codes::Z21 => ErcAction::RetryWithCorrection {
298            field: "vorgangsnummer",
299        },
300        codes::Z27 => ErcAction::RetryWithCorrection { field: "zaehlwert" },
301        codes::Z34 | codes::Z41 => ErcAction::RetryWithCorrection {
302            field: "zeitintervall",
303        },
304        codes::Z30 => ErcAction::RetryWithCorrection { field: "zeitreihe" },
305        codes::Z19 => ErcAction::RetryWithCorrection {
306            field: "geraetenummer",
307        },
308        codes::Z20 => ErcAction::RetryWithCorrection { field: "obis" },
309
310        // The receiver has decided. Nothing the sender resends changes it.
311        codes::Z31 | codes::Z37 | codes::Z43 => ErcAction::AbortProcess,
312
313        // The referenced Vorgang is not there yet — a correlation the receiver
314        // may still be processing.
315        codes::Z33 => ErcAction::WaitAndRetry {
316            reason: "referenced Geschäftsvorfall-Tupel not present at the receiver yet",
317        },
318
319        // An identity or an assignment the two sides disagree about: master
320        // data, not message content, so a person has to look.
321        codes::Z10 | codes::Z14 | codes::Z15 => ErcAction::EscalateToOperator {
322            reason: "the receiver does not know this ID — check the Marktpartner master data",
323        },
324        codes::Z16 => ErcAction::EscalateToOperator {
325            reason: "object has left the Netzgebiet — the receiving NB is no longer responsible",
326        },
327        codes::Z17 | codes::Z18 | codes::Z24 | codes::Z25 | codes::Z26 => {
328            ErcAction::EscalateToOperator {
329                reason: "party is not assigned to the object at that time — check the Zuordnung",
330            }
331        }
332        codes::Z42 => ErcAction::EscalateToOperator {
333            reason: "Konfigurations-ID unknown at the receiver",
334        },
335        codes::Z44 => ErcAction::EscalateToOperator {
336            reason: "the object's property differs from the one the Geschäftsvorfall codes",
337        },
338
339        _ => ErcAction::EscalateToOperator {
340            reason: "unknown ERC code — manual review required",
341        },
342    }
343}
344
345// ── Tests ─────────────────────────────────────────────────────────────────────
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn erc_code_roundtrips_json() {
353        let code = ErcCode::new(codes::Z29);
354        let json = serde_json::to_string(&code).unwrap();
355        assert_eq!(json, r#""Z29""#);
356        let back: ErcCode = serde_json::from_str(&json).unwrap();
357        assert_eq!(back, code);
358    }
359
360    #[test]
361    fn erc_code_display_matches_inner() {
362        let code = ErcCode::new(codes::Z39);
363        assert_eq!(code.to_string(), "Z39");
364        assert_eq!(code.as_str(), "Z39");
365    }
366
367    #[test]
368    fn erc_code_from_str() {
369        let code = ErcCode::from(codes::Z43);
370        assert_eq!(code.as_str(), codes::Z43);
371    }
372
373    #[test]
374    fn erc_code_as_ref() {
375        let code = ErcCode::new(codes::Z31);
376        let s: &str = code.as_ref();
377        assert_eq!(s, "Z31");
378    }
379
380    #[test]
381    fn a_message_level_defect_is_a_retry() {
382        for c in [codes::Z29, codes::Z35, codes::Z38, codes::Z39, codes::Z40] {
383            assert!(
384                matches!(
385                    recommended_action(&ErcCode::new(c)),
386                    ErcAction::RetryWithCorrection { field: "message" }
387                ),
388                "{c}",
389            );
390        }
391    }
392
393    #[test]
394    fn a_receiver_decision_is_not_retried() {
395        for c in [codes::Z31, codes::Z37, codes::Z43] {
396            assert_eq!(
397                recommended_action(&ErcCode::new(c)),
398                ErcAction::AbortProcess,
399                "{c}",
400            );
401        }
402    }
403
404    #[test]
405    fn an_unknown_correlation_waits() {
406        assert!(matches!(
407            recommended_action(&ErcCode::new(codes::Z33)),
408            ErcAction::WaitAndRetry { .. }
409        ));
410    }
411
412    #[test]
413    fn recommended_action_unknown_escalates() {
414        assert!(matches!(
415            recommended_action(&ErcCode::new("X99")),
416            ErcAction::EscalateToOperator { .. }
417        ));
418    }
419
420    /// A constant added without a `recommended_action` arm falls through to the
421    /// „unknown ERC code" escalation, which reads as a deliberate decision and
422    /// is not one.
423    #[test]
424    fn every_code_has_its_own_recommendation() {
425        for c in codes::ALL {
426            let action = recommended_action(&ErcCode::new(c));
427            assert!(
428                !matches!(
429                    action,
430                    ErcAction::EscalateToOperator {
431                        reason: "unknown ERC code — manual review required"
432                    }
433                ),
434                "{c} falls through to the catch-all",
435            );
436        }
437    }
438
439    /// The six codes the APERAK AHB conditions [5] and [9]–[13] name, and only
440    /// those. `edi_energy`'s builder emits the `SG5 FTX+Z02` for exactly this
441    /// set, so the two lists must not drift.
442    #[test]
443    fn the_ortsangabe_codes_match_the_ahb_conditions() {
444        let obliged: Vec<&str> = codes::ALL
445            .into_iter()
446            .filter(|c| requires_ortsangabe(c))
447            .collect();
448        assert_eq!(obliged, ["Z29", "Z35", "Z38", "Z39", "Z40", "Z41"]);
449    }
450
451    #[test]
452    fn erc_code_in_json_macro() {
453        // Ensure codes::* can be used directly in serde_json::json! macros
454        // (the primary use case in domain workflow outbox payloads).
455        let payload = serde_json::json!({
456            "error_code": codes::Z29,
457            "reason": "test",
458        });
459        assert_eq!(payload["error_code"], "Z29");
460    }
461}