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}