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}