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