Skip to main content

bo4e_edifact_types/
model.rs

1use serde::{Deserialize, Serialize};
2
3/// EDIFACT interchange envelope metadata (UNB/UNZ).
4#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5#[serde(rename_all = "camelCase")]
6pub struct Interchangedaten {
7    pub syntax_kennung: Option<String>,
8    pub absender_code: Option<String>,
9    pub empfaenger_code: Option<String>,
10    pub datum: Option<String>,
11    pub zeit: Option<String>,
12    pub interchange_ref: Option<String>,
13    /// UNB S002 0007: the sender's code qualifier (`500` BDEW, `14` GS1).
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub absender_qualifier: Option<String>,
16    /// UNB S003 0007: the receiver's code qualifier.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub empfaenger_qualifier: Option<String>,
19    /// UNB 0026: the Anwendungsreferenz (MSCONS: `VL`, `TL`, `EM`).
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub anwendungsreferenz: Option<String>,
22}
23
24/// Message-level metadata: the UNH/UNT envelope plus the BGM/DTM/FTX document
25/// fields the mappings model as the `Nachricht` entity.
26///
27/// One metadata slot per level is the shape: `Interchangedaten` for the UNB/UNZ
28/// interchange, this for the message, `transaktionsdaten` for each transaction.
29/// `Nachricht` is metadata *about* the message rather than a business object the
30/// message is about, so it belongs here and not among the BOs in `stammdaten`.
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct Nachrichtendaten {
34    pub unh_referenz: String,
35    pub nachrichten_typ: String,
36
37    /// UNH 0068 — Allgemeine Zuordnungs-Referenz.
38    ///
39    /// A free-text reference the sender may attach to the message. The AHB
40    /// marks it `Soll` on 53 Pruefidentifikatoren, so a message that loses it
41    /// no longer satisfies its own guide.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub zuordnungsreferenz: Option<String>,
44
45    /// UNH S010/0070 — Übermittlungsfolgenummer.
46    ///
47    /// Which transmission this is when one logical message is split across
48    /// several. Named "Laufende Nummer bei Aufteilung von Nachrichten" in the
49    /// UTILMD guides and "Übermittlungsfolgenummer" in MSCONS; same element.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub uebermittlungsfolgenummer: Option<String>,
52
53    /// UNH S010/0073 — Erste und letzte Übermittlung.
54    ///
55    /// Marks the first or the last transmission of a split message. The wire
56    /// carries `C`/`F`; BO4E carries the MIG's own names for them.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub uebermittlungsabschnitt: Option<Uebermittlungsabschnitt>,
59
60    /// BGM/DTM/FTX document metadata, flattened onto this object so the wire
61    /// carries one object per level rather than a nested pair.
62    ///
63    /// Held as a map, not as a typed `bo4e_mako::com::Nachricht`: the engine is
64    /// data-driven (see #92), so deserializing here would mean a fallible
65    /// conversion in the middle of the pipeline whose only honest failure
66    /// handling is to drop the fields. The typed view is proven instead by the
67    /// typed-hop gates, which cannot silently lose anything.
68    #[serde(flatten, default)]
69    pub nachricht: serde_json::Map<String, serde_json::Value>,
70}
71
72/// UNH S010/0073 — which end of a split message this transmission is.
73///
74/// The names are the MIG's (`Beginn`, `Ende`); the wire values are the EDIFACT
75/// codes they stand for. Keeping the pair here is what lets the BO4E carry the
76/// name while the reverse direction still writes the code.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub enum Uebermittlungsabschnitt {
80    Beginn,
81    Ende,
82}
83
84impl Uebermittlungsabschnitt {
85    /// The EDIFACT code for this value.
86    pub fn code(self) -> &'static str {
87        match self {
88            Self::Beginn => "C",
89            Self::Ende => "F",
90        }
91    }
92
93    /// The value an EDIFACT code stands for, or `None` for a code the MIG does
94    /// not define — which the forward direction keeps out of the BO4E rather
95    /// than inventing a name for.
96    pub fn from_code(code: &str) -> Option<Self> {
97        match code {
98            "C" => Some(Self::Beginn),
99            "F" => Some(Self::Ende),
100            _ => None,
101        }
102    }
103}
104
105/// A complete EDIFACT interchange. M = message stammdaten, T = transaction stammdaten.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct Interchange<M, T> {
109    pub interchangedaten: Interchangedaten,
110    pub nachrichten: Vec<Nachricht<M, T>>,
111}
112
113/// A single EDIFACT message within an interchange.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct Nachricht<M, T> {
117    pub nachrichtendaten: Nachrichtendaten,
118    pub stammdaten: M,
119    pub transaktionen: Vec<T>,
120}
121
122/// One transaction: the business objects it is about, and the metadata about
123/// the transaction itself.
124///
125/// `D` is the transaction metadata (the `Prozessdaten` entity), `S` the
126/// business objects. Splitting them is what gives every level the same shape —
127/// interchange, message and transaction each carry their own metadata slot
128/// beside their content.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct Transaktion<D, S> {
132    /// Defaulted so `{"stammdaten": …}` alone deserializes: a transaction that
133    /// carries no process data is normal, and rejecting it would make the
134    /// metadata slot mandatory on input when it is not mandatory in the data.
135    #[serde(default)]
136    pub transaktionsdaten: D,
137    pub stammdaten: S,
138}
139
140/// Whether this JSON is a [`Transaktion`] rather than a bare entity map.
141///
142/// Callers may hand a transaction over in either shape: the current
143/// `{transaktionsdaten, stammdaten}` wrapper, or the bare entity map used
144/// before the metadata slot existed. Both keys of the wrapper are optional in
145/// practice — `transaktionsdaten` is absent when a transaction carries no
146/// process data, and `stammdaten` when it carries no business objects — so
147/// either key alone identifies the wrapper.
148///
149/// A transaction of metadata alone is not a curiosity: UTILMD 55044 rejecting
150/// an Anmeldung with `ZC9` ("keine Zuordnung möglich") states a reason and
151/// nothing else, because a rejection that could not identify the Messlokation
152/// must not name one. Reading that as a bare entity map buries the metadata
153/// under an entity name no mapping defines and renders an empty message
154/// (issue #153), so the test asks about both keys, not just `stammdaten`.
155pub fn is_wrapped_transaktion(tx: &serde_json::Value) -> bool {
156    tx.get("stammdaten").is_some() || tx.get("transaktionsdaten").is_some()
157}
158
159/// Dynamic variant for untyped transaction usage.
160pub type DynamicTransaktion = Transaktion<serde_json::Value, serde_json::Value>;
161/// Dynamic variant for untyped usage.
162pub type DynamicInterchange = Interchange<serde_json::Value, DynamicTransaktion>;
163/// Dynamic variant for untyped message usage.
164pub type DynamicNachricht = Nachricht<serde_json::Value, DynamicTransaktion>;
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use serde_json::json;
170
171    #[test]
172    fn test_dynamic_interchange_serde_roundtrip() {
173        let interchange: DynamicInterchange = Interchange {
174            interchangedaten: Interchangedaten {
175                syntax_kennung: Some("UNOC".to_string()),
176                absender_code: Some("1234567890123".to_string()),
177                empfaenger_code: Some("9876543210987".to_string()),
178                datum: Some("260326".to_string()),
179                zeit: Some("1430".to_string()),
180                interchange_ref: Some("00001".to_string()),
181                absender_qualifier: None,
182                empfaenger_qualifier: None,
183                anwendungsreferenz: None,
184            },
185            nachrichten: vec![Nachricht {
186                nachrichtendaten: Nachrichtendaten {
187                    unh_referenz: "00001".to_string(),
188                    nachrichten_typ: "UTILMD".to_string(),
189                    nachricht: Default::default(),
190                    ..Default::default()
191                },
192                stammdaten: json!({"key": "value"}),
193                transaktionen: vec![
194                    Transaktion {
195                        transaktionsdaten: json!({"vorgangId": "1"}),
196                        stammdaten: json!({"tx": 1}),
197                    },
198                    Transaktion {
199                        transaktionsdaten: json!({"vorgangId": "2"}),
200                        stammdaten: json!({"tx": 2}),
201                    },
202                ],
203            }],
204        };
205
206        let json_str = serde_json::to_string(&interchange).unwrap();
207        let deserialized: DynamicInterchange = serde_json::from_str(&json_str).unwrap();
208
209        assert_eq!(
210            deserialized.interchangedaten.syntax_kennung,
211            Some("UNOC".to_string())
212        );
213        assert_eq!(
214            deserialized.interchangedaten.absender_code,
215            Some("1234567890123".to_string())
216        );
217        assert_eq!(
218            deserialized.interchangedaten.empfaenger_code,
219            Some("9876543210987".to_string())
220        );
221        assert_eq!(deserialized.nachrichten.len(), 1);
222        assert_eq!(
223            deserialized.nachrichten[0].nachrichtendaten.unh_referenz,
224            "00001"
225        );
226        assert_eq!(
227            deserialized.nachrichten[0].nachrichtendaten.nachrichten_typ,
228            "UTILMD"
229        );
230        assert_eq!(
231            deserialized.nachrichten[0].stammdaten,
232            json!({"key": "value"})
233        );
234        assert_eq!(deserialized.nachrichten[0].transaktionen.len(), 2);
235    }
236
237    #[test]
238    fn test_typed_interchange_serde_roundtrip() {
239        #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
240        struct TestStamm {
241            name: String,
242        }
243
244        #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
245        struct TestTx {
246            id: u32,
247        }
248
249        let interchange: Interchange<TestStamm, TestTx> = Interchange {
250            interchangedaten: Interchangedaten::default(),
251            nachrichten: vec![Nachricht {
252                nachrichtendaten: Nachrichtendaten {
253                    unh_referenz: "REF1".to_string(),
254                    nachrichten_typ: "ORDERS".to_string(),
255                    nachricht: Default::default(),
256                    ..Default::default()
257                },
258                stammdaten: TestStamm {
259                    name: "test".to_string(),
260                },
261                transaktionen: vec![TestTx { id: 1 }, TestTx { id: 2 }],
262            }],
263        };
264
265        let json_str = serde_json::to_string(&interchange).unwrap();
266        let deserialized: Interchange<TestStamm, TestTx> = serde_json::from_str(&json_str).unwrap();
267
268        assert_eq!(deserialized.nachrichten.len(), 1);
269        assert_eq!(
270            deserialized.nachrichten[0].stammdaten,
271            TestStamm {
272                name: "test".to_string()
273            }
274        );
275        assert_eq!(deserialized.nachrichten[0].transaktionen.len(), 2);
276        assert_eq!(
277            deserialized.nachrichten[0].transaktionen[0],
278            TestTx { id: 1 }
279        );
280        assert_eq!(
281            deserialized.nachrichten[0].transaktionen[1],
282            TestTx { id: 2 }
283        );
284    }
285
286    #[test]
287    fn test_interchangedaten_camel_case_serialization() {
288        let data = Interchangedaten {
289            syntax_kennung: Some("UNOC".to_string()),
290            absender_code: Some("SENDER".to_string()),
291            empfaenger_code: None,
292            datum: None,
293            zeit: None,
294            interchange_ref: Some("REF".to_string()),
295            absender_qualifier: None,
296            empfaenger_qualifier: None,
297            anwendungsreferenz: None,
298        };
299
300        let json_value: serde_json::Value = serde_json::to_value(&data).unwrap();
301        let obj = json_value.as_object().unwrap();
302
303        // Verify camelCase keys
304        assert!(obj.contains_key("syntaxKennung"));
305        assert!(obj.contains_key("absenderCode"));
306        assert!(obj.contains_key("empfaengerCode"));
307        assert!(obj.contains_key("datum"));
308        assert!(obj.contains_key("zeit"));
309        assert!(obj.contains_key("interchangeRef"));
310
311        // Verify no snake_case keys
312        assert!(!obj.contains_key("syntax_kennung"));
313        assert!(!obj.contains_key("absender_code"));
314        assert!(!obj.contains_key("empfaenger_code"));
315        assert!(!obj.contains_key("interchange_ref"));
316    }
317}