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//! - **CONTRL** `ERC` segments: syntax and data-validation errors.
7//!
8//! This module provides a validated [`ErcCode`] newtype, a catalogue of
9//! standard code string constants in [`codes`], and [`ErcAction`] — a
10//! machine-readable recommended automated response for each code.
11//! Domain crates `match` on the ERC code to drive typed ERP automation
12//! instead of freeform text parsing.
13//!
14//! # Separation of concerns
15//!
16//! | Layer | Responsibility |
17//! |---|---|
18//! | `edi-energy` | Wire-format parsing; raw `String` from ERC segment |
19//! | `mako-engine::erc` | Validated type; constants; role-agnostic [`ErcAction`] recommendation |
20//! | Domain crates | Process-specific `match` on [`ErcCode`] → domain decision |
21//! | `makod` | [`ErcCode`] in outbox payload → `makoerc` CloudEvents extension |
22//!
23//! # Regulatory sources
24//!
25//! - APERAK AHB 1.0 (FV2025-10-01 / FV2026-10-01) — ERC segment, §2.2/§2.3
26//! - CONTRL AHB 1.0 (FV2025-10-01 / FV2026-10-01) — ERC segment, §2.2
27//! - Allgemeine Festlegungen V6.1d (01.04.2026) — §4 rejection handling
28//!
29//! # Example
30//!
31//! ```rust
32//! use mako_engine::erc::{ErcCode, ErcAction, codes, recommended_action};
33//!
34//! let code = ErcCode::new(codes::E02);
35//! assert!(matches!(
36//!     recommended_action(&code),
37//!     ErcAction::RetryWithCorrection { field: "address" }
38//! ));
39//! ```
40
41use serde::{Deserialize, Serialize};
42
43// ── ErcCode ───────────────────────────────────────────────────────────────────
44
45/// A BDEW ERC error code from an inbound APERAK or CONTRL.
46///
47/// Wraps an arbitrary string.  Use [`codes`] for known BDEW constants.
48/// Use [`ErcCode::new`] for codes parsed from inbound EDIFACT that may not
49/// be in the known set (e.g. proprietary NB codes).
50///
51/// Implements `Serialize`/`Deserialize` as a transparent JSON string so it
52/// passes through CloudEvents payloads unchanged.
53#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct ErcCode(Box<str>);
56
57impl ErcCode {
58    /// Wrap an arbitrary string as an ERC code.
59    ///
60    /// No validation is applied — malformed codes from counterparties are
61    /// accepted for forensic purposes and matched via `==` or
62    /// [`recommended_action`].
63    pub fn new(code: impl Into<Box<str>>) -> Self {
64        Self(code.into())
65    }
66
67    /// Return the code string (e.g. `"Z29"`).
68    #[must_use]
69    pub fn as_str(&self) -> &str {
70        &self.0
71    }
72}
73
74impl std::fmt::Display for ErcCode {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str(&self.0)
77    }
78}
79
80impl AsRef<str> for ErcCode {
81    fn as_ref(&self) -> &str {
82        &self.0
83    }
84}
85
86impl From<&str> for ErcCode {
87    fn from(s: &str) -> Self {
88        Self::new(s)
89    }
90}
91
92// ── ErcAction ─────────────────────────────────────────────────────────────────
93
94/// Recommended automated response for a received ERC rejection code.
95///
96/// This is **advice**, not a hard rule.  The ERP decides whether to follow
97/// it based on local policy, retry budget, and operator escalation settings.
98///
99/// Source: BDEW APERAK AHB 1.0; CONTRL AHB 1.0; Allgemeine Festlegungen V6.1d §4.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum ErcAction {
102    /// Correct the named field and re-submit the process.
103    RetryWithCorrection {
104        /// Short identifier of the field to correct
105        /// (e.g. `"malo_id"`, `"address"`, `"process_date"`).
106        field: &'static str,
107    },
108    /// Escalate to an operator for manual investigation.
109    EscalateToOperator {
110        /// Brief reason string for the operator notification.
111        reason: &'static str,
112    },
113    /// Abort the process — the counterparty has definitively rejected it.
114    AbortProcess,
115    /// Wait for a conflicting in-flight process to finish, then retry.
116    WaitAndRetry {
117        /// Human-readable description of the blocking condition.
118        reason: &'static str,
119    },
120}
121
122// ── Standard BDEW ERC code string constants ───────────────────────────────────
123
124/// Standard BDEW ERC error code string constants.
125///
126/// These are `&'static str` values so they can be used directly inside
127/// `serde_json::json!` macro expressions:
128///
129/// ```rust
130/// use mako_engine::erc::codes;
131///
132/// let payload = serde_json::json!({ "error_code": codes::Z29 });
133/// assert_eq!(payload["error_code"], "Z29");
134/// ```
135///
136/// Use [`ErcCode::new(codes::Z29)`][ErcCode::new] when a rich typed value is
137/// needed (e.g. for storing in workflow state or `ErpEventType::AperakRejected`).
138pub mod codes {
139    // ── APERAK ERC codes (BGM+313 processability errors) ─────────────────────
140
141    /// Ablehnung — Prozess nicht gefunden / sonstiger Fehler.
142    ///
143    /// Catchall code for messages that cannot be routed to any active process.
144    /// Source: APERAK AHB 1.0.
145    pub const Z29: &str = "Z29";
146
147    /// Marktlokation / Identifikationsnummer nicht gefunden.
148    ///
149    /// The MaLo-ID in the message is not registered with the receiver.
150    /// Source: CONTRL AHB 1.0 / APERAK AHB 1.0.
151    pub const Z43: &str = "Z43";
152
153    /// Ablehnung — Zähler in Betrieb (Sperrung nicht ausführbar).
154    ///
155    /// NB cannot execute a Sperrung because the meter is currently live.
156    /// Process terminates; no retry is appropriate.
157    /// Source: ORDERS/ORDRSP Sperrung AHB.
158    pub const ZB3: &str = "ZB3";
159
160    /// Ablehnung — Lieferstelle gesperrt.
161    pub const Z28: &str = "Z28";
162
163    /// Ablehnung — kein aktiver Prozess vorhanden.
164    pub const Z30: &str = "Z30";
165
166    /// Ablehnung — Zeitraum nicht plausibel.
167    pub const Z04: &str = "Z04";
168
169    /// Ablehnung — nicht autorisiert.
170    pub const Z07: &str = "Z07";
171
172    /// Ablehnung — Zählernummer nicht plausibel.
173    pub const Z08: &str = "Z08";
174
175    /// Ablehnung — Messlokation ungültig.
176    pub const Z09: &str = "Z09";
177
178    // ── CONTRL ERC codes (syntax / data validation errors) ────────────────────
179
180    /// MaLo / Identifikationsnummer unbekannt.
181    ///
182    /// Source: BDEW CONTRL AHB 1.0; Allgemeine Festlegungen V6.1d.
183    pub const E01: &str = "E01";
184
185    /// Adresse stimmt nicht überein.
186    pub const E02: &str = "E02";
187
188    /// Kein gültiger Lieferant für diese Marktlokation.
189    pub const E03: &str = "E03";
190
191    /// Datum liegt in der Vergangenheit / Datum nicht plausibel.
192    pub const E04: &str = "E04";
193
194    /// Wechsel nicht möglich — laufender Prozess bereits vorhanden.
195    pub const E05: &str = "E05";
196
197    /// Ungültige Prüfidentifikatornummer.
198    pub const E06: &str = "E06";
199}
200
201// ── recommended_action ────────────────────────────────────────────────────────
202
203/// Return the recommended automated ERP action for a received ERC code.
204///
205/// The table covers the common LF-relevant codes defined in APERAK AHB 1.0
206/// and CONTRL AHB 1.0.  Unknown codes default to
207/// [`ErcAction::EscalateToOperator`] so nothing is silently swallowed.
208///
209/// # Example
210///
211/// ```rust
212/// use mako_engine::erc::{ErcCode, ErcAction, codes, recommended_action};
213///
214/// let code = ErcCode::new(codes::E05);
215/// assert!(matches!(recommended_action(&code), ErcAction::WaitAndRetry { .. }));
216/// ```
217#[must_use]
218pub fn recommended_action(code: &ErcCode) -> ErcAction {
219    match code.as_str() {
220        // CONTRL codes
221        codes::E01 | codes::Z43 => ErcAction::RetryWithCorrection { field: "malo_id" },
222        codes::E02 => ErcAction::RetryWithCorrection { field: "address" },
223        codes::E03 => ErcAction::EscalateToOperator {
224            reason: "LF GLN not recognised by NB — check Marktteilnehmerverzeichnis",
225        },
226        codes::E04 | codes::Z04 => ErcAction::RetryWithCorrection {
227            field: "process_date",
228        },
229        codes::E05 => ErcAction::WaitAndRetry {
230            reason: "concurrent in-flight process present; wait for completion then retry",
231        },
232        codes::E06 => ErcAction::EscalateToOperator {
233            reason: "invalid Prüfidentifikator in outbound message — engineering alert",
234        },
235        // APERAK codes
236        codes::Z07 => ErcAction::EscalateToOperator {
237            reason: "not authorised for this supply point",
238        },
239        codes::Z08 => ErcAction::RetryWithCorrection { field: "meter_id" },
240        codes::Z09 => ErcAction::RetryWithCorrection { field: "melo_id" },
241        codes::Z28 | codes::Z30 | codes::ZB3 => ErcAction::AbortProcess,
242        codes::Z29 => ErcAction::EscalateToOperator {
243            reason: "process not found or unclassified error",
244        },
245        _ => ErcAction::EscalateToOperator {
246            reason: "unknown ERC code — manual review required",
247        },
248    }
249}
250
251// ── Tests ─────────────────────────────────────────────────────────────────────
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn erc_code_roundtrips_json() {
259        let code = ErcCode::new(codes::Z29);
260        let json = serde_json::to_string(&code).unwrap();
261        assert_eq!(json, r#""Z29""#);
262        let back: ErcCode = serde_json::from_str(&json).unwrap();
263        assert_eq!(back, code);
264    }
265
266    #[test]
267    fn erc_code_display_matches_inner() {
268        let code = ErcCode::new(codes::E02);
269        assert_eq!(code.to_string(), "E02");
270        assert_eq!(code.as_str(), "E02");
271    }
272
273    #[test]
274    fn erc_code_from_str() {
275        let code = ErcCode::from(codes::Z43);
276        assert_eq!(code.as_str(), codes::Z43);
277    }
278
279    #[test]
280    fn erc_code_as_ref() {
281        let code = ErcCode::new(codes::ZB3);
282        let s: &str = code.as_ref();
283        assert_eq!(s, "ZB3");
284    }
285
286    #[test]
287    fn recommended_action_e01_retry_malo() {
288        assert!(matches!(
289            recommended_action(&ErcCode::new(codes::E01)),
290            ErcAction::RetryWithCorrection { field: "malo_id" }
291        ));
292    }
293
294    #[test]
295    fn recommended_action_e02_retry_address() {
296        assert!(matches!(
297            recommended_action(&ErcCode::new(codes::E02)),
298            ErcAction::RetryWithCorrection { field: "address" }
299        ));
300    }
301
302    #[test]
303    fn recommended_action_e04_retry_date() {
304        assert!(matches!(
305            recommended_action(&ErcCode::new(codes::E04)),
306            ErcAction::RetryWithCorrection {
307                field: "process_date"
308            }
309        ));
310    }
311
312    #[test]
313    fn recommended_action_e05_wait_and_retry() {
314        assert!(matches!(
315            recommended_action(&ErcCode::new(codes::E05)),
316            ErcAction::WaitAndRetry { .. }
317        ));
318    }
319
320    #[test]
321    fn recommended_action_e03_escalate() {
322        assert!(matches!(
323            recommended_action(&ErcCode::new(codes::E03)),
324            ErcAction::EscalateToOperator { .. }
325        ));
326    }
327
328    #[test]
329    fn recommended_action_e06_escalate() {
330        assert!(matches!(
331            recommended_action(&ErcCode::new(codes::E06)),
332            ErcAction::EscalateToOperator { .. }
333        ));
334    }
335
336    #[test]
337    fn recommended_action_z29_escalate() {
338        assert!(matches!(
339            recommended_action(&ErcCode::new(codes::Z29)),
340            ErcAction::EscalateToOperator { .. }
341        ));
342    }
343
344    #[test]
345    fn recommended_action_zb3_abort() {
346        assert_eq!(
347            recommended_action(&ErcCode::new(codes::ZB3)),
348            ErcAction::AbortProcess
349        );
350    }
351
352    #[test]
353    fn recommended_action_z28_abort() {
354        assert_eq!(
355            recommended_action(&ErcCode::new(codes::Z28)),
356            ErcAction::AbortProcess
357        );
358    }
359
360    #[test]
361    fn recommended_action_z30_abort() {
362        assert_eq!(
363            recommended_action(&ErcCode::new(codes::Z30)),
364            ErcAction::AbortProcess
365        );
366    }
367
368    #[test]
369    fn recommended_action_z43_retry_malo() {
370        assert!(matches!(
371            recommended_action(&ErcCode::new(codes::Z43)),
372            ErcAction::RetryWithCorrection { field: "malo_id" }
373        ));
374    }
375
376    #[test]
377    fn recommended_action_unknown_escalates() {
378        assert!(matches!(
379            recommended_action(&ErcCode::new("X99")),
380            ErcAction::EscalateToOperator { .. }
381        ));
382    }
383
384    #[test]
385    fn all_standard_codes_have_recommendations() {
386        // Every code in the `codes` module must return a non-default action
387        // (i.e. not fall through to the catch-all EscalateToOperator for
388        // "unknown ERC code").  This test guards against adding a constant
389        // without updating the match table.
390        for (code_str, expected_variant) in [
391            (codes::E01, "RetryWithCorrection"),
392            (codes::E02, "RetryWithCorrection"),
393            (codes::E03, "EscalateToOperator"),
394            (codes::E04, "RetryWithCorrection"),
395            (codes::E05, "WaitAndRetry"),
396            (codes::E06, "EscalateToOperator"),
397            (codes::Z04, "RetryWithCorrection"),
398            (codes::Z07, "EscalateToOperator"),
399            (codes::Z08, "RetryWithCorrection"),
400            (codes::Z09, "RetryWithCorrection"),
401            (codes::Z28, "AbortProcess"),
402            (codes::Z29, "EscalateToOperator"),
403            (codes::Z30, "AbortProcess"),
404            (codes::Z43, "RetryWithCorrection"),
405            (codes::ZB3, "AbortProcess"),
406        ] {
407            let action = recommended_action(&ErcCode::new(code_str));
408            let variant_name = match &action {
409                ErcAction::RetryWithCorrection { .. } => "RetryWithCorrection",
410                ErcAction::EscalateToOperator { .. } => "EscalateToOperator",
411                ErcAction::AbortProcess => "AbortProcess",
412                ErcAction::WaitAndRetry { .. } => "WaitAndRetry",
413            };
414            assert_eq!(
415                variant_name, expected_variant,
416                "ERC code {code_str}: expected {expected_variant}, got {variant_name}"
417            );
418        }
419    }
420
421    #[test]
422    fn erc_code_in_json_macro() {
423        // Ensure codes::* can be used directly in serde_json::json! macros
424        // (the primary use case in domain workflow outbox payloads).
425        let payload = serde_json::json!({
426            "error_code": codes::Z29,
427            "reason": "test",
428        });
429        assert_eq!(payload["error_code"], "Z29");
430    }
431}