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