mako_engine/lf_vorgang.rs
1//! The `de.mako.process.initiated` contract for an **LF-answered Vorgang**.
2//!
3//! Nine inbound Prüfidentifikatoren put a supplier in the answering seat, split
4//! across two Festlegungen — GPKE Strom (`mako-gpke`) and GeLi Gas
5//! (`mako-geli-gas`) — but answered by **one** decision path in `processd`.
6//! The workflows differ per Sparte; the facts their notification must carry do
7//! not, because [`mako_pruefung`]'s trees branch on the same five `SG4`
8//! elements whichever Festlegung the message came from.
9//!
10//! So the contract lives here rather than in either workflow crate: both depend
11//! on `mako-engine` already, and a payload built twice is a payload that drifts.
12//! A Gas payload without the Transaktionsgrundergänzung escalates every Gas walk
13//! at Prüfschritt 10.
14//!
15//! [`mako_pruefung`]: https://docs.rs/mako-pruefung
16//!
17//! # Absent is not null
18//!
19//! [`LfVorgangsdaten::process_initiated`] omits a field the message did not
20//! carry instead of writing `null`. A consumer that tests *presence* — and
21//! `DTM+471` is exactly such a test, because its presence **is** the answer to
22//! `E_0614` Prüfschritt 60 — reads `Some(Value::Null)` as „present", and every
23//! Kündigung then looks like one „zum nächstmöglichen Termin": the branch that
24//! may not be refused for Vertragsbindung.
25
26use crate::outbox::PendingOutbox;
27use crate::types::{MaLo, MarktpartnerCode, Pruefidentifikator};
28
29/// The `SG4` facts an LF-answered Vorgang carries beyond its Lokations-ID.
30///
31/// Every LF tree branches on at least one of them, so a
32/// `de.mako.process.initiated` that omits them cannot be walked: the
33/// Transaktionsgrundergänzung picks the code range, the Transaktionsgrund picks
34/// the branch, `DTM+154` starts `E_0624`'s own Frist and `DTM+471` decides
35/// whether `E_0614` may refuse for Vertragsbindung at all.
36///
37/// Kept in one type so the Strom and Gas workflows cannot drift apart on what
38/// they publish, and so a new fact is added once.
39#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40#[serde(default, deny_unknown_fields)]
41pub struct LfVorgangsdaten {
42 /// `SG4 STS+7` DE 9013 element 2 — `Z33`, `ZQ7`, `ZT0`, `E01`, `E03`, …
43 pub transaktionsgrund: Option<String>,
44 /// `SG4 STS+7` DE 9013 element 3 — `ZW3` / `ZW4` / `ZW5` / `ZAP`.
45 pub transaktionsgrund_ergaenzung: Option<String>,
46 /// `SG4 IDE+24` DE 7402 — the sender's Vorgangsnummer.
47 ///
48 /// The answer references it in `SG4 RFF+TN`; it is never reused as the
49 /// answer's own `IDE+24`, which the MIG requires to be globally unique.
50 pub vorgangsnummer: Option<String>,
51 /// `SG4 DTM+154` — ÜT der Lieferanmeldung des LFN, on a 55010.
52 ///
53 /// `E_0624` Prüfschritt 5 measures its own Frist from it and answers `A43`
54 /// when the NB asked too late.
55 pub uet_lieferanmeldung: Option<String>,
56 /// `SG4 DTM+471` — „Ende zum nächstmöglichen Termin", on a 55016 / 44016.
57 ///
58 /// Present **instead of** `DTM+93`. `E_0614` Prüfschritt 60 branches on
59 /// which of the two arrived, and only the fixed date may be refused for
60 /// Vertragsbindung — so this field's *presence* is load-bearing and it is
61 /// omitted, never nulled, when the message carried a fixed date.
62 pub naechstmoeglicher_termin: Option<String>,
63 /// `SG12 NAD+Z09` `C080` — „Kunde des LF", the customer name the request
64 /// carries, joined from the composite's up-to-five DE 3036 components.
65 ///
66 /// `E_0624` Prüfschritt 50 („Ist der Kunde aus der Anfrage zur Beendigung
67 /// der Zuordnung identisch mit dem Kunden beim LFA?") is answerable only
68 /// from this: the UTILMD AHB marks the segment **Muss** on a 55010 whose
69 /// Transaktionsgrundergänzung is `ZW4`/`ZAP` (Bedingung `[279]`), and
70 /// Bedingung `[572]` says what it is — „Kundenname aus Anmeldung Lieferant
71 /// neu". Without it the whole Ein-/Auszug arm (`A32`/`A33`/`A34`)
72 /// escalates, which is a large share of all switches.
73 pub kunde_name: Option<String>,
74 /// `SG12 NAD+Z09` `C080` DE 3045 — the Namensformat: `Z01` Struktur von
75 /// Personennamen, `Z02` Struktur der Firmenbezeichnung.
76 ///
77 /// It says how to read [`Self::kunde_name`]: five interchangeable
78 /// components are a person (Nachname, Vorname, …) under `Z01` and a company
79 /// name under `Z02`, and a comparison that ignores the difference matches a
80 /// person against a company.
81 pub kunde_namensformat: Option<String>,
82 /// `SG12 NAD+VY` DE 3039 — the **Neulieferant**'s MP-ID (Bedingung `[567]`).
83 ///
84 /// The 55010 is the only message that names the LFN to the LFA before the
85 /// switch completes; it is what reconciles an Anfrage against a Kündigung
86 /// the LFA already answered.
87 pub lfn_mp_id: Option<String>,
88}
89
90impl LfVorgangsdaten {
91 /// The `de.mako.process.initiated` notification for an inbound Vorgang.
92 ///
93 /// Without it `processd`'s LF module never sees the message: `makod`
94 /// delivers a CloudEvent only for an outbox entry, and an APERAK is a
95 /// technical acknowledgement, not a business notification.
96 ///
97 /// `extra` merges Sparte-specific facts that other consumers need — the
98 /// Gas Bilanzierungsmethode, Fallgruppe and Gasqualität `marktd` folds into
99 /// the Marktlokation. Keys it does not set are left alone, so an `extra`
100 /// can never silently drop a fact a tree branches on.
101 ///
102 /// # Panics
103 ///
104 /// Never in practice: the panic guards the `json!` literal below, which is
105 /// an object by construction.
106 #[must_use]
107 pub fn process_initiated(
108 &self,
109 pid: Pruefidentifikator,
110 malo_id: &MaLo,
111 sender: &MarktpartnerCode,
112 receiver: &MarktpartnerCode,
113 process_date: &str,
114 extra: &serde_json::Value,
115 ) -> PendingOutbox {
116 let mut payload = serde_json::json!({
117 "pid": pid.as_u32(),
118 "malo_id": malo_id.as_str(),
119 "sender": sender.as_str(),
120 "receiver": receiver.as_str(),
121 // The counterparty is the NB on 55007/55010/55607/44007/44010 and
122 // the LFN on a 55016/44016; `processd` reads whichever is set.
123 "grid_operator": sender.as_str(),
124 "process_date": process_date,
125 "termin": process_date,
126 });
127
128 // Absent, not null — see the module docs.
129 let obj = payload.as_object_mut().expect("json! built an object");
130 let mut set = |key: &str, value: &Option<String>| {
131 if let Some(v) = value {
132 obj.insert(key.to_owned(), serde_json::Value::String(v.clone()));
133 }
134 };
135 set("transaktionsgrund", &self.transaktionsgrund);
136 set(
137 "transaktionsgrund_ergaenzung",
138 &self.transaktionsgrund_ergaenzung,
139 );
140 set("vorgangsnummer", &self.vorgangsnummer);
141 set("uet_lieferanmeldung", &self.uet_lieferanmeldung);
142 set("naechstmoeglicher_termin", &self.naechstmoeglicher_termin);
143 set("kunde_name", &self.kunde_name);
144 set("kunde_namensformat", &self.kunde_namensformat);
145 set("lfn_mp_id", &self.lfn_mp_id);
146
147 if let Some(extra) = extra.as_object() {
148 for (k, v) in extra {
149 obj.entry(k.clone()).or_insert_with(|| v.clone());
150 }
151 }
152
153 PendingOutbox::new("ProcessInitiated", receiver.as_str(), payload)
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 fn outbox(vorgang: &LfVorgangsdaten, extra: &serde_json::Value) -> serde_json::Value {
162 vorgang
163 .process_initiated(
164 Pruefidentifikator::new(55_016).expect("valid PID"),
165 &MaLo::new("51238696012"),
166 &MarktpartnerCode::new("9900357000004"),
167 &MarktpartnerCode::new("9900000000001"),
168 "20260901",
169 extra,
170 )
171 .payload
172 .clone()
173 }
174
175 /// A fact the message did not carry is **absent**, not `null`. `DTM+471`'s
176 /// presence is the answer to `E_0614` Prüfschritt 60, so a `null` there
177 /// reads as „Kündigung zum nächstmöglichen Termin" — the branch that may
178 /// never be refused for Vertragsbindung.
179 #[test]
180 fn a_missing_fact_is_absent_rather_than_null() {
181 let p = outbox(&LfVorgangsdaten::default(), &serde_json::Value::Null);
182 for key in [
183 "transaktionsgrund",
184 "transaktionsgrund_ergaenzung",
185 "vorgangsnummer",
186 "uet_lieferanmeldung",
187 "naechstmoeglicher_termin",
188 "kunde_name",
189 "kunde_namensformat",
190 "lfn_mp_id",
191 ] {
192 assert!(p.get(key).is_none(), "{key} must be absent, got {p:#}");
193 }
194 }
195
196 /// Every fact the trees branch on reaches the payload.
197 #[test]
198 fn every_branching_fact_is_carried() {
199 let p = outbox(
200 &LfVorgangsdaten {
201 transaktionsgrund: Some("E03".into()),
202 transaktionsgrund_ergaenzung: Some("ZW4".into()),
203 vorgangsnummer: Some("NNV1234".into()),
204 uet_lieferanmeldung: Some("20260820".into()),
205 naechstmoeglicher_termin: Some("20261231".into()),
206 kunde_name: Some("Mustermann Erika".into()),
207 kunde_namensformat: Some("Z01".into()),
208 lfn_mp_id: Some("9900357000004".into()),
209 },
210 &serde_json::Value::Null,
211 );
212 assert_eq!(p["transaktionsgrund"], "E03");
213 assert_eq!(p["transaktionsgrund_ergaenzung"], "ZW4");
214 assert_eq!(p["vorgangsnummer"], "NNV1234");
215 assert_eq!(p["uet_lieferanmeldung"], "20260820");
216 assert_eq!(p["naechstmoeglicher_termin"], "20261231");
217 assert_eq!(p["kunde_name"], "Mustermann Erika");
218 assert_eq!(p["kunde_namensformat"], "Z01");
219 assert_eq!(p["lfn_mp_id"], "9900357000004");
220 }
221
222 /// Sparte-specific facts ride along, and cannot displace a fact a tree
223 /// branches on.
224 #[test]
225 fn extras_are_merged_without_overwriting() {
226 let p = outbox(
227 &LfVorgangsdaten {
228 transaktionsgrund: Some("E03".into()),
229 ..LfVorgangsdaten::default()
230 },
231 &serde_json::json!({
232 "bilanzierungsmethode": "SLP",
233 "gasqualitaet": "H-Gas",
234 "transaktionsgrund": "SHOULD-NOT-WIN",
235 }),
236 );
237 assert_eq!(p["bilanzierungsmethode"], "SLP");
238 assert_eq!(p["gasqualitaet"], "H-Gas");
239 assert_eq!(p["transaktionsgrund"], "E03");
240 }
241
242 /// The notification is addressed to us — the party that must answer.
243 #[test]
244 fn the_notification_is_addressed_to_the_answering_party() {
245 let ob = LfVorgangsdaten::default().process_initiated(
246 Pruefidentifikator::new(55_007).expect("valid PID"),
247 &MaLo::new("51238696012"),
248 &MarktpartnerCode::new("9900357000004"),
249 &MarktpartnerCode::new("9900000000001"),
250 "20260901",
251 &serde_json::Value::Null,
252 );
253 assert_eq!(&*ob.recipient, "9900000000001");
254 assert_eq!(ob.payload["grid_operator"], "9900357000004");
255 }
256}