Skip to main content

mig_bo4e/
model.rs

1//! Output model types for the MIG-driven mapping pipeline.
2//!
3//! Public types `Interchange`, `Nachricht`, `DynamicInterchange`, `DynamicNachricht`,
4//! `Interchangedaten`, `Nachrichtendaten` are re-exported from `bo4e-edifact-types`.
5//!
6//! Internal engine types `MappedMessage` and `MappedTransaktion` carry forward-mapping
7//! results including `nesting_info` metadata that is not part of the public API.
8
9use mig_assembly::assembler::AssembledSegment;
10use mig_types::segment::OwnedSegment;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, HashMap};
13
14// Re-export public model types from bo4e-edifact-types
15pub use bo4e_edifact_types::{
16    DynamicInterchange, DynamicNachricht, DynamicTransaktion, Interchange, Interchangedaten,
17    Nachricht, Nachrichtendaten, Transaktion,
18};
19
20/// Internal engine type for a forward-mapped transaction.
21///
22/// Contains all BO4E entities (including prozessdaten) in `stammdaten`,
23/// plus nesting distribution info used by the reverse mapper.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct MappedTransaktion {
27    /// The business objects this transaction is about.
28    /// Keys are entity names in camelCase (e.g. "marktlokation", "messlokation").
29    pub stammdaten: serde_json::Value,
30
31    /// Metadata about the transaction itself — the `Prozessdaten` entity,
32    /// split out of `stammdaten` on the way out and merged back on the way in.
33    ///
34    /// This is the `transaktionsdaten` half of the BO4E market-communication
35    /// shape. It is metadata, not a business object, so it does not belong
36    /// among the BOs. Null when a transaction carries no process data.
37    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
38    pub transaktionsdaten: serde_json::Value,
39
40    /// Nesting distribution info for transaction-level entities.
41    ///
42    /// Maps entity key (camelCase) -> parent rep index for each child element.
43    /// Used by the reverse mapper to distribute children among parent group reps
44    /// within a transaction (e.g., SG36->SG40 in PRICAT).
45    /// Derived from the tree structure during forward mapping; never serialized.
46    #[serde(skip)]
47    pub nesting_info: HashMap<String, Vec<usize>>,
48}
49
50/// Intermediate result from mapping a single message's assembled tree.
51///
52/// Contains message-level stammdaten and per-transaction results.
53/// Used by `MappingEngine::map_interchange()` before wrapping into `Nachricht`.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct MappedMessage {
57    /// Message-level BO4E entities (e.g. Marktteilnehmer from SG2).
58    pub stammdaten: serde_json::Value,
59
60    /// The `Nachricht` entity, split out of `stammdaten` on the way out.
61    ///
62    /// Engine-internal: [`MappedMessage::into_dynamic_nachricht`] folds it into
63    /// [`Nachrichtendaten`], the message's one metadata slot. Deliberately not
64    /// named `transaktionsdaten` — there is no transaction at message level.
65    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
66    pub nachricht_meta: serde_json::Value,
67
68    /// Per-transaction results (one per SG4 instance).
69    pub transaktionen: Vec<MappedTransaktion>,
70
71    /// Nesting distribution info for message-level entities.
72    ///
73    /// Maps entity key (camelCase) -> parent rep index for each child element.
74    /// Used by the reverse mapper to distribute children among parent group reps.
75    /// Derived from the tree structure during forward mapping; never serialized.
76    #[serde(skip)]
77    pub nesting_info: HashMap<String, Vec<usize>>,
78
79    /// Inter-group segments captured by the assembler at message scope.
80    ///
81    /// Contains both schema-recognized root segments emitted between groups
82    /// (e.g. UNS+S in MSCONS / ORDERS) and PID-foreign segments preserved by
83    /// `skip_unknown_segments` mode (e.g. IMD in QUOTES 15005). Threaded
84    /// through MappedMessage so that `map_interchange_reverse` can hand
85    /// them back to the disassembler for byte-identical roundtrip — without
86    /// this, BO4E forward + reverse drops anything not represented in a
87    /// TOML mapping definition.
88    #[serde(skip)]
89    pub inter_group_segments: BTreeMap<usize, Vec<AssembledSegment>>,
90}
91
92impl MappedMessage {
93    /// Convert this internal engine result into a public `DynamicNachricht`.
94    ///
95    /// Each `MappedTransaktion.stammdaten` becomes a transaction entry in the
96    /// `DynamicNachricht.transaktionen` Vec.
97    pub fn into_dynamic_nachricht(self, nachrichtendaten: Nachrichtendaten) -> DynamicNachricht {
98        // Fold the message's `Nachricht` entity into its metadata slot. Moved,
99        // not deserialized: a fallible conversion here could only fail by
100        // dropping fields, and this is the public shape.
101        let mut nachrichtendaten = nachrichtendaten;
102        if let serde_json::Value::Object(fields) = self.nachricht_meta {
103            nachrichtendaten.nachricht = fields;
104        }
105
106        Nachricht {
107            nachrichtendaten,
108            stammdaten: self.stammdaten,
109            transaktionen: self
110                .transaktionen
111                .into_iter()
112                .map(|t| Transaktion {
113                    transaktionsdaten: t.transaktionsdaten,
114                    stammdaten: t.stammdaten,
115                })
116                .collect(),
117        }
118    }
119}
120
121/// The entity key holding per-transaction process metadata.
122///
123/// Split out of `stammdaten` into `transaktionsdaten` on the way out and merged
124/// back on the way in, so the reverse mapper keeps seeing the single flat entity
125/// map it resolves definitions against. Naming it once here keeps the forward
126/// split and the reverse merge from drifting apart.
127pub const TX_METADATA_ENTITY: &str = "prozessdaten";
128
129/// The entity key holding message-level document metadata.
130pub const MSG_METADATA_ENTITY: &str = "nachricht";
131
132/// Move `key` out of `from` and return it, leaving `from` without that key.
133pub fn take_entity(from: &mut serde_json::Value, key: &str) -> serde_json::Value {
134    from.as_object_mut()
135        .and_then(|m| m.remove(key))
136        .unwrap_or(serde_json::Value::Null)
137}
138
139/// Put `value` back under `key` — the inverse of [`take_entity`].
140///
141/// A no-op for a null value, so a message that never had the entity does not
142/// gain an empty key on the way back.
143pub fn restore_entity(into: &mut serde_json::Value, key: &str, value: &serde_json::Value) {
144    if value.is_null() {
145        return;
146    }
147    if let Some(m) = into.as_object_mut() {
148        m.insert(key.to_string(), value.clone());
149    }
150}
151
152/// Put the message's metadata entity back into `msg_stammdaten` for the reverse.
153///
154/// The forward pass moves `Nachricht` out of `stammdaten` and into
155/// [`Nachrichtendaten`]. The reverse resolves definitions against a flat entity
156/// map, so a caller holding a whole message must hand the entity back before
157/// mapping or the BGM/DTM segments it feeds cannot be rebuilt.
158pub fn restore_message_metadata(msg_stammdaten: &mut serde_json::Value, nd: &Nachrichtendaten) {
159    if nd.nachricht.is_empty() {
160        return;
161    }
162    let value = serde_json::Value::Object(nd.nachricht.clone());
163    restore_entity(msg_stammdaten, MSG_METADATA_ENTITY, &value);
164}
165
166/// Extract message reference and message type from a UNH segment.
167pub fn extract_unh_fields(unh: &OwnedSegment) -> (String, String) {
168    let referenz = unh.get_element(0).to_string();
169    let typ = unh.get_component(1, 0).to_string();
170    (referenz, typ)
171}
172
173/// Extract typed interchange-level metadata from envelope segments (UNB).
174pub fn extract_interchangedaten(envelope: &[OwnedSegment]) -> Interchangedaten {
175    let mut result = Interchangedaten::default();
176
177    for seg in envelope {
178        if seg.is("UNB") {
179            let val = |s: &str| {
180                if s.is_empty() {
181                    None
182                } else {
183                    Some(s.to_string())
184                }
185            };
186            result.syntax_kennung = val(seg.get_component(0, 0));
187            result.absender_code = val(seg.get_component(1, 0));
188            result.empfaenger_code = val(seg.get_component(2, 0));
189            result.datum = val(seg.get_component(3, 0));
190            result.zeit = val(seg.get_component(3, 1));
191            result.interchange_ref = val(seg.get_element(4));
192        }
193    }
194
195    result
196}
197
198/// Extract interchange-level metadata from envelope segments (UNB) as JSON.
199///
200/// Kept for backward compatibility. Prefer `extract_interchangedaten()` for typed access.
201pub fn extract_nachrichtendaten(envelope: &[OwnedSegment]) -> serde_json::Value {
202    let data = extract_interchangedaten(envelope);
203    serde_json::to_value(&data).unwrap_or_default()
204}
205
206/// Normalize a date string to UNB S004 YYMMDD format (6 digits).
207///
208/// UNB with UNOC:3 syntax uses YYMMDD (6 digits), not CCYYMMDD (8 digits).
209/// If an 8-digit CCYYMMDD date is provided, the century prefix is stripped.
210fn normalize_unb_datum(datum: &str) -> &str {
211    if datum.len() == 8 && datum.as_bytes().iter().all(|b| b.is_ascii_digit()) {
212        &datum[2..]
213    } else {
214        datum
215    }
216}
217
218/// Rebuild a UNB (interchange header) segment from typed `Interchangedaten`.
219///
220/// This is the inverse of `extract_interchangedaten()`.
221/// Fields not present get sensible defaults (UNOC:3, "500" qualifier).
222/// Dates in CCYYMMDD (8-digit) format are automatically normalized to YYMMDD (6-digit).
223pub fn rebuild_unb_from_interchangedaten(data: &Interchangedaten) -> OwnedSegment {
224    let syntax = data.syntax_kennung.as_deref().unwrap_or("UNOC");
225    let sender = data.absender_code.as_deref().unwrap_or("");
226    let receiver = data.empfaenger_code.as_deref().unwrap_or("");
227    let datum = normalize_unb_datum(data.datum.as_deref().unwrap_or(""));
228    let zeit = data.zeit.as_deref().unwrap_or("");
229    let interchange_ref = data.interchange_ref.as_deref().unwrap_or("00000");
230
231    OwnedSegment {
232        id: "UNB".to_string(),
233        elements: vec![
234            vec![syntax.to_string(), "3".to_string()],
235            vec![sender.to_string(), "500".to_string()],
236            vec![receiver.to_string(), "500".to_string()],
237            vec![datum.to_string(), zeit.to_string()],
238            vec![interchange_ref.to_string()],
239        ],
240        segment_number: 0,
241    }
242}
243
244/// Rebuild a UNB (interchange header) segment from nachrichtendaten JSON.
245///
246/// This is the inverse of `extract_nachrichtendaten()`.
247/// Fields not present in the JSON get sensible defaults (UNOC:3, "500" qualifier).
248/// Dates in CCYYMMDD (8-digit) format are automatically normalized to YYMMDD (6-digit).
249pub fn rebuild_unb(nachrichtendaten: &serde_json::Value) -> OwnedSegment {
250    let syntax = nachrichtendaten
251        .get("syntaxKennung")
252        .and_then(|v| v.as_str())
253        .unwrap_or("UNOC");
254    let sender = nachrichtendaten
255        .get("absenderCode")
256        .and_then(|v| v.as_str())
257        .unwrap_or("");
258    let receiver = nachrichtendaten
259        .get("empfaengerCode")
260        .and_then(|v| v.as_str())
261        .unwrap_or("");
262    let datum_raw = nachrichtendaten
263        .get("datum")
264        .and_then(|v| v.as_str())
265        .unwrap_or("");
266    let datum = normalize_unb_datum(datum_raw);
267    let zeit = nachrichtendaten
268        .get("zeit")
269        .and_then(|v| v.as_str())
270        .unwrap_or("");
271    let interchange_ref = nachrichtendaten
272        .get("interchangeRef")
273        .and_then(|v| v.as_str())
274        .unwrap_or("00000");
275
276    OwnedSegment {
277        id: "UNB".to_string(),
278        elements: vec![
279            vec![syntax.to_string(), "3".to_string()],
280            vec![sender.to_string(), "500".to_string()],
281            vec![receiver.to_string(), "500".to_string()],
282            vec![datum.to_string(), zeit.to_string()],
283            vec![interchange_ref.to_string()],
284        ],
285        segment_number: 0,
286    }
287}
288
289/// Rebuild a UNH (message header) segment.
290///
291/// Produces: `UNH+referenz+typ:D:{release}:UN:{association}`.
292///
293/// `release` (S009 d0054) and `association` (S009 d0057) are message-type and
294/// MIG-version specific — UTILMD Strom is `11A`/`S2.1`, UTILMD Gas `11A`/`G1.0a`,
295/// MSCONS `04B`/`2.4c`. Hardcoding them makes every reverse-rendered message fail
296/// the AHB code rule on S009 for every variant but UTILMD Strom, so callers pass
297/// the values from the MIG they mapped against
298/// ([`release_code_for_message_type`] and `MigSchema::version`).
299pub fn rebuild_unh(
300    referenz: &str,
301    nachrichten_typ: &str,
302    release: &str,
303    association: &str,
304) -> OwnedSegment {
305    OwnedSegment {
306        id: "UNH".to_string(),
307        elements: vec![
308            vec![referenz.to_string()],
309            vec![
310                nachrichten_typ.to_string(),
311                "D".to_string(),
312                release.to_string(),
313                "UN".to_string(),
314                association.to_string(),
315            ],
316        ],
317        segment_number: 0,
318    }
319}
320
321/// UN/EDIFACT directory release code (UNH S009 d0054) for a message type.
322///
323/// Pinned against the AHB's allowed codes by
324/// `edifact-mapper/tests/unh_release_codes.rs` — a wrong value here makes every
325/// generated message fail the code rule on UNH S009. The values are the same
326/// across all format versions the repo ships.
327pub fn release_code_for_message_type(msg_type: &str) -> &'static str {
328    match msg_type {
329        "APERAK" => "07B",
330        "COMDIS" => "17A",
331        // CONTRL is versioned by syntax level, not by a UN/EDIFACT directory.
332        "CONTRL" => "3",
333        "IFTSTA" => "18A",
334        "INSRPT" => "10A",
335        "INVOIC" => "06A",
336        "MSCONS" => "04B",
337        "ORDCHG" => "20B",
338        "ORDERS" => "09B",
339        "ORDRSP" => "10A",
340        "PARTIN" => "20B",
341        "PRICAT" => "20B",
342        "QUOTES" => "10A",
343        "REMADV" => "05A",
344        "REQOTE" => "10A",
345        "UTILMD" => "11A",
346        "UTILTS" => "18A",
347        _ => "04B", // fallback
348    }
349}
350
351/// Rebuild a UNT (message trailer) segment.
352///
353/// Produces: `UNT+count+referenz`
354/// `segment_count` includes UNH and UNT themselves.
355pub fn rebuild_unt(segment_count: usize, referenz: &str) -> OwnedSegment {
356    OwnedSegment {
357        id: "UNT".to_string(),
358        elements: vec![vec![segment_count.to_string()], vec![referenz.to_string()]],
359        segment_number: 0,
360    }
361}
362
363/// Rebuild a UNZ (interchange trailer) segment.
364///
365/// Produces: `UNZ+count+ref`
366pub fn rebuild_unz(message_count: usize, interchange_ref: &str) -> OwnedSegment {
367    OwnedSegment {
368        id: "UNZ".to_string(),
369        elements: vec![
370            vec![message_count.to_string()],
371            vec![interchange_ref.to_string()],
372        ],
373        segment_number: 0,
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_mapped_transaktion_serde_roundtrip() {
383        let tx = MappedTransaktion {
384            transaktionsdaten: serde_json::Value::Null,
385            stammdaten: serde_json::json!({
386                "prozessdaten": {
387                    "vorgangId": "TX001",
388                    "transaktionsgrund": "E01"
389                },
390                "marktlokation": { "marktlokationsId": "DE000111222333" }
391            }),
392            nesting_info: Default::default(),
393        };
394
395        let json = serde_json::to_string(&tx).unwrap();
396        let de: MappedTransaktion = serde_json::from_str(&json).unwrap();
397        assert_eq!(
398            de.stammdaten["prozessdaten"]["vorgangId"].as_str().unwrap(),
399            "TX001"
400        );
401        assert!(de.stammdaten["marktlokation"].is_object());
402    }
403
404    #[test]
405    fn test_dynamic_nachricht_serde_roundtrip() {
406        let msg: DynamicNachricht = Nachricht {
407            nachrichtendaten: Nachrichtendaten {
408                unh_referenz: "00001".to_string(),
409                nachrichten_typ: "UTILMD".to_string(),
410                nachricht: Default::default(),
411            },
412            stammdaten: serde_json::json!({
413                "marktteilnehmer": [
414                    { "marktrolle": "MS", "rollencodenummer": "9900123" }
415                ]
416            }),
417            transaktionen: vec![Transaktion {
418                transaktionsdaten: serde_json::Value::Null,
419                stammdaten: serde_json::json!({}),
420            }],
421        };
422
423        let json = serde_json::to_string(&msg).unwrap();
424        let de: DynamicNachricht = serde_json::from_str(&json).unwrap();
425        assert_eq!(de.nachrichtendaten.unh_referenz, "00001");
426        assert_eq!(de.nachrichtendaten.nachrichten_typ, "UTILMD");
427        assert_eq!(de.transaktionen.len(), 1);
428    }
429
430    #[test]
431    fn test_dynamic_interchange_serde_roundtrip() {
432        let interchange: DynamicInterchange = Interchange {
433            interchangedaten: Interchangedaten {
434                absender_code: Some("9900123456789".to_string()),
435                empfaenger_code: Some("9900987654321".to_string()),
436                ..Default::default()
437            },
438            nachrichten: vec![Nachricht {
439                nachrichtendaten: Nachrichtendaten {
440                    unh_referenz: "00001".to_string(),
441                    nachrichten_typ: "UTILMD".to_string(),
442                    nachricht: Default::default(),
443                },
444                stammdaten: serde_json::json!({}),
445                transaktionen: vec![],
446            }],
447        };
448
449        let json = serde_json::to_string_pretty(&interchange).unwrap();
450        let de: DynamicInterchange = serde_json::from_str(&json).unwrap();
451        assert_eq!(de.nachrichten.len(), 1);
452        assert_eq!(de.nachrichten[0].nachrichtendaten.unh_referenz, "00001");
453    }
454
455    #[test]
456    fn test_extract_interchangedaten_from_segments() {
457        let envelope = vec![OwnedSegment {
458            id: "UNB".to_string(),
459            elements: vec![
460                vec!["UNOC".to_string(), "3".to_string()],
461                vec!["9900123456789".to_string(), "500".to_string()],
462                vec!["9900987654321".to_string(), "500".to_string()],
463                vec!["210101".to_string(), "1200".to_string()],
464                vec!["REF001".to_string()],
465            ],
466            segment_number: 0,
467        }];
468
469        let data = extract_interchangedaten(&envelope);
470        assert_eq!(data.absender_code.as_deref(), Some("9900123456789"));
471        assert_eq!(data.empfaenger_code.as_deref(), Some("9900987654321"));
472        assert_eq!(data.interchange_ref.as_deref(), Some("REF001"));
473        assert_eq!(data.syntax_kennung.as_deref(), Some("UNOC"));
474        assert_eq!(data.datum.as_deref(), Some("210101"));
475        assert_eq!(data.zeit.as_deref(), Some("1200"));
476    }
477
478    #[test]
479    fn test_extract_envelope_from_segments_json() {
480        let envelope = vec![OwnedSegment {
481            id: "UNB".to_string(),
482            elements: vec![
483                vec!["UNOC".to_string(), "3".to_string()],
484                vec!["9900123456789".to_string(), "500".to_string()],
485                vec!["9900987654321".to_string(), "500".to_string()],
486                vec!["210101".to_string(), "1200".to_string()],
487                vec!["REF001".to_string()],
488            ],
489            segment_number: 0,
490        }];
491
492        let nd = extract_nachrichtendaten(&envelope);
493        assert_eq!(nd["absenderCode"].as_str().unwrap(), "9900123456789");
494        assert_eq!(nd["empfaengerCode"].as_str().unwrap(), "9900987654321");
495        assert_eq!(nd["interchangeRef"].as_str().unwrap(), "REF001");
496        assert_eq!(nd["syntaxKennung"].as_str().unwrap(), "UNOC");
497        assert_eq!(nd["datum"].as_str().unwrap(), "210101");
498        assert_eq!(nd["zeit"].as_str().unwrap(), "1200");
499    }
500
501    #[test]
502    fn test_extract_unh_fields() {
503        let unh = OwnedSegment {
504            id: "UNH".to_string(),
505            elements: vec![
506                vec!["MSG001".to_string()],
507                vec![
508                    "UTILMD".to_string(),
509                    "D".to_string(),
510                    "11A".to_string(),
511                    "UN".to_string(),
512                    "S2.1".to_string(),
513                ],
514            ],
515            segment_number: 0,
516        };
517
518        let (referenz, typ) = extract_unh_fields(&unh);
519        assert_eq!(referenz, "MSG001");
520        assert_eq!(typ, "UTILMD");
521    }
522
523    #[test]
524    fn test_rebuild_unb_from_interchangedaten_typed() {
525        let data = Interchangedaten {
526            syntax_kennung: Some("UNOC".to_string()),
527            absender_code: Some("9900123456789".to_string()),
528            empfaenger_code: Some("9900987654321".to_string()),
529            datum: Some("210101".to_string()),
530            zeit: Some("1200".to_string()),
531            interchange_ref: Some("REF001".to_string()),
532        };
533
534        let unb = rebuild_unb_from_interchangedaten(&data);
535        assert_eq!(unb.id, "UNB");
536        assert_eq!(unb.elements[0], vec!["UNOC", "3"]);
537        assert_eq!(unb.elements[1][0], "9900123456789");
538        assert_eq!(unb.elements[2][0], "9900987654321");
539        assert_eq!(unb.elements[3], vec!["210101", "1200"]);
540        assert_eq!(unb.elements[4], vec!["REF001"]);
541    }
542
543    #[test]
544    fn test_rebuild_unb_from_nachrichtendaten() {
545        let nd = serde_json::json!({
546            "syntaxKennung": "UNOC",
547            "absenderCode": "9900123456789",
548            "empfaengerCode": "9900987654321",
549            "datum": "210101",
550            "zeit": "1200",
551            "interchangeRef": "REF001"
552        });
553
554        let unb = rebuild_unb(&nd);
555        assert_eq!(unb.id, "UNB");
556        assert_eq!(unb.elements[0], vec!["UNOC", "3"]);
557        assert_eq!(unb.elements[1][0], "9900123456789");
558        assert_eq!(unb.elements[2][0], "9900987654321");
559        assert_eq!(unb.elements[3], vec!["210101", "1200"]);
560        assert_eq!(unb.elements[4], vec!["REF001"]);
561    }
562
563    #[test]
564    fn test_rebuild_unb_defaults() {
565        let nd = serde_json::json!({});
566        let unb = rebuild_unb(&nd);
567        assert_eq!(unb.id, "UNB");
568        assert_eq!(unb.elements[0], vec!["UNOC", "3"]);
569    }
570
571    #[test]
572    fn test_rebuild_unh() {
573        let unh = rebuild_unh("00001", "UTILMD", "11A", "S2.1");
574        assert_eq!(unh.id, "UNH");
575        assert_eq!(unh.elements[0], vec!["00001"]);
576        assert_eq!(unh.elements[1][0], "UTILMD");
577        assert_eq!(unh.elements[1][1], "D");
578        assert_eq!(unh.elements[1][2], "11A");
579        assert_eq!(unh.elements[1][3], "UN");
580        assert_eq!(unh.elements[1][4], "S2.1");
581    }
582
583    #[test]
584    fn test_rebuild_unh_uses_the_given_release_and_association() {
585        // UTILMD Gas rides on the same D:11A directory but a different MIG
586        // version; hardcoding S2.1 fails the AHB code rule on UNH S009.
587        let unh = rebuild_unh("00001", "UTILMD", "11A", "G1.0a");
588        assert_eq!(unh.elements[1], vec!["UTILMD", "D", "11A", "UN", "G1.0a"]);
589
590        let unh = rebuild_unh(
591            "00001",
592            "MSCONS",
593            release_code_for_message_type("MSCONS"),
594            "2.4c",
595        );
596        assert_eq!(unh.elements[1], vec!["MSCONS", "D", "04B", "UN", "2.4c"]);
597    }
598
599    #[test]
600    fn test_rebuild_unt() {
601        let unt = rebuild_unt(25, "00001");
602        assert_eq!(unt.id, "UNT");
603        assert_eq!(unt.elements[0], vec!["25"]);
604        assert_eq!(unt.elements[1], vec!["00001"]);
605    }
606
607    #[test]
608    fn test_rebuild_unz() {
609        let unz = rebuild_unz(1, "REF001");
610        assert_eq!(unz.id, "UNZ");
611        assert_eq!(unz.elements[0], vec!["1"]);
612        assert_eq!(unz.elements[1], vec!["REF001"]);
613    }
614
615    #[test]
616    fn test_roundtrip_interchangedaten_rebuild() {
617        let original = OwnedSegment {
618            id: "UNB".to_string(),
619            elements: vec![
620                vec!["UNOC".to_string(), "3".to_string()],
621                vec!["9900123456789".to_string(), "500".to_string()],
622                vec!["9900987654321".to_string(), "500".to_string()],
623                vec!["210101".to_string(), "1200".to_string()],
624                vec!["REF001".to_string()],
625            ],
626            segment_number: 0,
627        };
628
629        let data = extract_interchangedaten(&[original]);
630        let rebuilt = rebuild_unb_from_interchangedaten(&data);
631        assert_eq!(rebuilt.elements[0], vec!["UNOC", "3"]);
632        assert_eq!(rebuilt.elements[1][0], "9900123456789");
633        assert_eq!(rebuilt.elements[2][0], "9900987654321");
634        assert_eq!(rebuilt.elements[3], vec!["210101", "1200"]);
635        assert_eq!(rebuilt.elements[4], vec!["REF001"]);
636    }
637
638    #[test]
639    fn test_roundtrip_nachrichtendaten_rebuild() {
640        let original = OwnedSegment {
641            id: "UNB".to_string(),
642            elements: vec![
643                vec!["UNOC".to_string(), "3".to_string()],
644                vec!["9900123456789".to_string(), "500".to_string()],
645                vec!["9900987654321".to_string(), "500".to_string()],
646                vec!["210101".to_string(), "1200".to_string()],
647                vec!["REF001".to_string()],
648            ],
649            segment_number: 0,
650        };
651
652        let nd = extract_nachrichtendaten(&[original]);
653        let rebuilt = rebuild_unb(&nd);
654        assert_eq!(rebuilt.elements[0], vec!["UNOC", "3"]);
655        assert_eq!(rebuilt.elements[1][0], "9900123456789");
656        assert_eq!(rebuilt.elements[2][0], "9900987654321");
657        assert_eq!(rebuilt.elements[3], vec!["210101", "1200"]);
658        assert_eq!(rebuilt.elements[4], vec!["REF001"]);
659    }
660
661    #[test]
662    fn test_rebuild_unb_normalizes_ccyymmdd_to_yymmdd() {
663        // UNB S004 datum must be YYMMDD (6 digits), not CCYYMMDD (8 digits)
664        let data = Interchangedaten {
665            syntax_kennung: Some("UNOC".to_string()),
666            absender_code: Some("9900000000003".to_string()),
667            empfaenger_code: Some("9900000000001".to_string()),
668            datum: Some("20260409".to_string()), // 8-digit CCYYMMDD input
669            zeit: Some("0725".to_string()),
670            interchange_ref: Some("00004".to_string()),
671        };
672
673        let unb = rebuild_unb_from_interchangedaten(&data);
674        assert_eq!(unb.elements[3], vec!["260409", "0725"]); // normalized to 6-digit YYMMDD
675
676        // Same via JSON path
677        let nd = serde_json::json!({
678            "syntaxKennung": "UNOC",
679            "absenderCode": "9900000000003",
680            "empfaengerCode": "9900000000001",
681            "datum": "20260409",
682            "zeit": "0725",
683            "interchangeRef": "00004"
684        });
685        let unb_json = rebuild_unb(&nd);
686        assert_eq!(unb_json.elements[3], vec!["260409", "0725"]);
687    }
688
689    #[test]
690    fn test_rebuild_unb_preserves_yymmdd() {
691        // Already 6-digit YYMMDD — should pass through unchanged
692        let data = Interchangedaten {
693            datum: Some("260409".to_string()),
694            zeit: Some("0725".to_string()),
695            ..Default::default()
696        };
697        let unb = rebuild_unb_from_interchangedaten(&data);
698        assert_eq!(unb.elements[3], vec!["260409", "0725"]);
699    }
700
701    #[test]
702    fn test_into_dynamic_nachricht() {
703        let mapped = MappedMessage {
704            nachricht_meta: serde_json::Value::Null,
705            stammdaten: serde_json::json!({"marktteilnehmer": []}),
706            transaktionen: vec![MappedTransaktion {
707                transaktionsdaten: serde_json::json!({"vorgangId": "1"}),
708                stammdaten: serde_json::json!({}),
709                nesting_info: Default::default(),
710            }],
711            nesting_info: Default::default(),
712            inter_group_segments: Default::default(),
713        };
714
715        let nd = Nachrichtendaten {
716            unh_referenz: "00001".to_string(),
717            nachrichten_typ: "UTILMD".to_string(),
718            nachricht: Default::default(),
719        };
720
721        let nachricht = mapped.into_dynamic_nachricht(nd);
722        assert_eq!(nachricht.nachrichtendaten.unh_referenz, "00001");
723        assert_eq!(nachricht.transaktionen.len(), 1);
724        // The transaction's metadata now sits in its own slot, not among the BOs.
725        assert_eq!(
726            nachricht.transaktionen[0].transaktionsdaten["vorgangId"],
727            "1"
728        );
729    }
730}