Skip to main content

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}