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.
267pub fn release_code_for_message_type(msg_type: &str) -> &'static str {
268    match msg_type {
269        "APERAK" => "07B",
270        "COMDIS" => "17A",
271        "CONTRL" => "04B",
272        "IFTSTA" => "18A",
273        "INSRPT" => "18A",
274        "INVOIC" => "06A",
275        "MSCONS" => "04B",
276        "ORDCHG" => "09B",
277        "ORDERS" => "09B",
278        "ORDRSP" => "10A",
279        "PARTIN" => "20B",
280        "PRICAT" => "20B",
281        "QUOTES" => "10A",
282        "REMADV" => "05A",
283        "REQOTE" => "10A",
284        "UTILMD" => "11A",
285        "UTILTS" => "18A",
286        _ => "04B", // fallback
287    }
288}
289
290/// Rebuild a UNT (message trailer) segment.
291///
292/// Produces: `UNT+count+referenz`
293/// `segment_count` includes UNH and UNT themselves.
294pub fn rebuild_unt(segment_count: usize, referenz: &str) -> OwnedSegment {
295    OwnedSegment {
296        id: "UNT".to_string(),
297        elements: vec![vec![segment_count.to_string()], vec![referenz.to_string()]],
298        segment_number: 0,
299    }
300}
301
302/// Rebuild a UNZ (interchange trailer) segment.
303///
304/// Produces: `UNZ+count+ref`
305pub fn rebuild_unz(message_count: usize, interchange_ref: &str) -> OwnedSegment {
306    OwnedSegment {
307        id: "UNZ".to_string(),
308        elements: vec![
309            vec![message_count.to_string()],
310            vec![interchange_ref.to_string()],
311        ],
312        segment_number: 0,
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_mapped_transaktion_serde_roundtrip() {
322        let tx = MappedTransaktion {
323            stammdaten: serde_json::json!({
324                "prozessdaten": {
325                    "vorgangId": "TX001",
326                    "transaktionsgrund": "E01"
327                },
328                "marktlokation": { "marktlokationsId": "DE000111222333" }
329            }),
330            nesting_info: Default::default(),
331            dp_routing: Default::default(),
332        };
333
334        let json = serde_json::to_string(&tx).unwrap();
335        let de: MappedTransaktion = serde_json::from_str(&json).unwrap();
336        assert_eq!(
337            de.stammdaten["prozessdaten"]["vorgangId"].as_str().unwrap(),
338            "TX001"
339        );
340        assert!(de.stammdaten["marktlokation"].is_object());
341    }
342
343    #[test]
344    fn test_dynamic_nachricht_serde_roundtrip() {
345        let msg: DynamicNachricht = Nachricht {
346            nachrichtendaten: Nachrichtendaten {
347                unh_referenz: "00001".to_string(),
348                nachrichten_typ: "UTILMD".to_string(),
349            },
350            stammdaten: serde_json::json!({
351                "marktteilnehmer": [
352                    { "marktrolle": "MS", "rollencodenummer": "9900123" }
353                ]
354            }),
355            transaktionen: vec![serde_json::json!({})],
356        };
357
358        let json = serde_json::to_string(&msg).unwrap();
359        let de: DynamicNachricht = serde_json::from_str(&json).unwrap();
360        assert_eq!(de.nachrichtendaten.unh_referenz, "00001");
361        assert_eq!(de.nachrichtendaten.nachrichten_typ, "UTILMD");
362        assert_eq!(de.transaktionen.len(), 1);
363    }
364
365    #[test]
366    fn test_dynamic_interchange_serde_roundtrip() {
367        let interchange: DynamicInterchange = Interchange {
368            interchangedaten: Interchangedaten {
369                absender_code: Some("9900123456789".to_string()),
370                empfaenger_code: Some("9900987654321".to_string()),
371                ..Default::default()
372            },
373            nachrichten: vec![Nachricht {
374                nachrichtendaten: Nachrichtendaten {
375                    unh_referenz: "00001".to_string(),
376                    nachrichten_typ: "UTILMD".to_string(),
377                },
378                stammdaten: serde_json::json!({}),
379                transaktionen: vec![],
380            }],
381        };
382
383        let json = serde_json::to_string_pretty(&interchange).unwrap();
384        let de: DynamicInterchange = serde_json::from_str(&json).unwrap();
385        assert_eq!(de.nachrichten.len(), 1);
386        assert_eq!(de.nachrichten[0].nachrichtendaten.unh_referenz, "00001");
387    }
388
389    #[test]
390    fn test_extract_interchangedaten_from_segments() {
391        let envelope = vec![OwnedSegment {
392            id: "UNB".to_string(),
393            elements: vec![
394                vec!["UNOC".to_string(), "3".to_string()],
395                vec!["9900123456789".to_string(), "500".to_string()],
396                vec!["9900987654321".to_string(), "500".to_string()],
397                vec!["210101".to_string(), "1200".to_string()],
398                vec!["REF001".to_string()],
399            ],
400            segment_number: 0,
401        }];
402
403        let data = extract_interchangedaten(&envelope);
404        assert_eq!(data.absender_code.as_deref(), Some("9900123456789"));
405        assert_eq!(data.empfaenger_code.as_deref(), Some("9900987654321"));
406        assert_eq!(data.interchange_ref.as_deref(), Some("REF001"));
407        assert_eq!(data.syntax_kennung.as_deref(), Some("UNOC"));
408        assert_eq!(data.datum.as_deref(), Some("210101"));
409        assert_eq!(data.zeit.as_deref(), Some("1200"));
410    }
411
412    #[test]
413    fn test_extract_envelope_from_segments_json() {
414        let envelope = vec![OwnedSegment {
415            id: "UNB".to_string(),
416            elements: vec![
417                vec!["UNOC".to_string(), "3".to_string()],
418                vec!["9900123456789".to_string(), "500".to_string()],
419                vec!["9900987654321".to_string(), "500".to_string()],
420                vec!["210101".to_string(), "1200".to_string()],
421                vec!["REF001".to_string()],
422            ],
423            segment_number: 0,
424        }];
425
426        let nd = extract_nachrichtendaten(&envelope);
427        assert_eq!(nd["absenderCode"].as_str().unwrap(), "9900123456789");
428        assert_eq!(nd["empfaengerCode"].as_str().unwrap(), "9900987654321");
429        assert_eq!(nd["interchangeRef"].as_str().unwrap(), "REF001");
430        assert_eq!(nd["syntaxKennung"].as_str().unwrap(), "UNOC");
431        assert_eq!(nd["datum"].as_str().unwrap(), "210101");
432        assert_eq!(nd["zeit"].as_str().unwrap(), "1200");
433    }
434
435    #[test]
436    fn test_extract_unh_fields() {
437        let unh = OwnedSegment {
438            id: "UNH".to_string(),
439            elements: vec![
440                vec!["MSG001".to_string()],
441                vec![
442                    "UTILMD".to_string(),
443                    "D".to_string(),
444                    "11A".to_string(),
445                    "UN".to_string(),
446                    "S2.1".to_string(),
447                ],
448            ],
449            segment_number: 0,
450        };
451
452        let (referenz, typ) = extract_unh_fields(&unh);
453        assert_eq!(referenz, "MSG001");
454        assert_eq!(typ, "UTILMD");
455    }
456
457    #[test]
458    fn test_rebuild_unb_from_interchangedaten_typed() {
459        let data = Interchangedaten {
460            syntax_kennung: Some("UNOC".to_string()),
461            absender_code: Some("9900123456789".to_string()),
462            empfaenger_code: Some("9900987654321".to_string()),
463            datum: Some("210101".to_string()),
464            zeit: Some("1200".to_string()),
465            interchange_ref: Some("REF001".to_string()),
466        };
467
468        let unb = rebuild_unb_from_interchangedaten(&data);
469        assert_eq!(unb.id, "UNB");
470        assert_eq!(unb.elements[0], vec!["UNOC", "3"]);
471        assert_eq!(unb.elements[1][0], "9900123456789");
472        assert_eq!(unb.elements[2][0], "9900987654321");
473        assert_eq!(unb.elements[3], vec!["210101", "1200"]);
474        assert_eq!(unb.elements[4], vec!["REF001"]);
475    }
476
477    #[test]
478    fn test_rebuild_unb_from_nachrichtendaten() {
479        let nd = serde_json::json!({
480            "syntaxKennung": "UNOC",
481            "absenderCode": "9900123456789",
482            "empfaengerCode": "9900987654321",
483            "datum": "210101",
484            "zeit": "1200",
485            "interchangeRef": "REF001"
486        });
487
488        let unb = rebuild_unb(&nd);
489        assert_eq!(unb.id, "UNB");
490        assert_eq!(unb.elements[0], vec!["UNOC", "3"]);
491        assert_eq!(unb.elements[1][0], "9900123456789");
492        assert_eq!(unb.elements[2][0], "9900987654321");
493        assert_eq!(unb.elements[3], vec!["210101", "1200"]);
494        assert_eq!(unb.elements[4], vec!["REF001"]);
495    }
496
497    #[test]
498    fn test_rebuild_unb_defaults() {
499        let nd = serde_json::json!({});
500        let unb = rebuild_unb(&nd);
501        assert_eq!(unb.id, "UNB");
502        assert_eq!(unb.elements[0], vec!["UNOC", "3"]);
503    }
504
505    #[test]
506    fn test_rebuild_unh() {
507        let unh = rebuild_unh("00001", "UTILMD", "11A", "S2.1");
508        assert_eq!(unh.id, "UNH");
509        assert_eq!(unh.elements[0], vec!["00001"]);
510        assert_eq!(unh.elements[1][0], "UTILMD");
511        assert_eq!(unh.elements[1][1], "D");
512        assert_eq!(unh.elements[1][2], "11A");
513        assert_eq!(unh.elements[1][3], "UN");
514        assert_eq!(unh.elements[1][4], "S2.1");
515    }
516
517    #[test]
518    fn test_rebuild_unh_uses_the_given_release_and_association() {
519        // UTILMD Gas rides on the same D:11A directory but a different MIG
520        // version; hardcoding S2.1 fails the AHB code rule on UNH S009.
521        let unh = rebuild_unh("00001", "UTILMD", "11A", "G1.0a");
522        assert_eq!(unh.elements[1], vec!["UTILMD", "D", "11A", "UN", "G1.0a"]);
523
524        let unh = rebuild_unh(
525            "00001",
526            "MSCONS",
527            release_code_for_message_type("MSCONS"),
528            "2.4c",
529        );
530        assert_eq!(unh.elements[1], vec!["MSCONS", "D", "04B", "UN", "2.4c"]);
531    }
532
533    #[test]
534    fn test_rebuild_unt() {
535        let unt = rebuild_unt(25, "00001");
536        assert_eq!(unt.id, "UNT");
537        assert_eq!(unt.elements[0], vec!["25"]);
538        assert_eq!(unt.elements[1], vec!["00001"]);
539    }
540
541    #[test]
542    fn test_rebuild_unz() {
543        let unz = rebuild_unz(1, "REF001");
544        assert_eq!(unz.id, "UNZ");
545        assert_eq!(unz.elements[0], vec!["1"]);
546        assert_eq!(unz.elements[1], vec!["REF001"]);
547    }
548
549    #[test]
550    fn test_roundtrip_interchangedaten_rebuild() {
551        let original = OwnedSegment {
552            id: "UNB".to_string(),
553            elements: vec![
554                vec!["UNOC".to_string(), "3".to_string()],
555                vec!["9900123456789".to_string(), "500".to_string()],
556                vec!["9900987654321".to_string(), "500".to_string()],
557                vec!["210101".to_string(), "1200".to_string()],
558                vec!["REF001".to_string()],
559            ],
560            segment_number: 0,
561        };
562
563        let data = extract_interchangedaten(&[original]);
564        let rebuilt = rebuild_unb_from_interchangedaten(&data);
565        assert_eq!(rebuilt.elements[0], vec!["UNOC", "3"]);
566        assert_eq!(rebuilt.elements[1][0], "9900123456789");
567        assert_eq!(rebuilt.elements[2][0], "9900987654321");
568        assert_eq!(rebuilt.elements[3], vec!["210101", "1200"]);
569        assert_eq!(rebuilt.elements[4], vec!["REF001"]);
570    }
571
572    #[test]
573    fn test_roundtrip_nachrichtendaten_rebuild() {
574        let original = OwnedSegment {
575            id: "UNB".to_string(),
576            elements: vec![
577                vec!["UNOC".to_string(), "3".to_string()],
578                vec!["9900123456789".to_string(), "500".to_string()],
579                vec!["9900987654321".to_string(), "500".to_string()],
580                vec!["210101".to_string(), "1200".to_string()],
581                vec!["REF001".to_string()],
582            ],
583            segment_number: 0,
584        };
585
586        let nd = extract_nachrichtendaten(&[original]);
587        let rebuilt = rebuild_unb(&nd);
588        assert_eq!(rebuilt.elements[0], vec!["UNOC", "3"]);
589        assert_eq!(rebuilt.elements[1][0], "9900123456789");
590        assert_eq!(rebuilt.elements[2][0], "9900987654321");
591        assert_eq!(rebuilt.elements[3], vec!["210101", "1200"]);
592        assert_eq!(rebuilt.elements[4], vec!["REF001"]);
593    }
594
595    #[test]
596    fn test_rebuild_unb_normalizes_ccyymmdd_to_yymmdd() {
597        // UNB S004 datum must be YYMMDD (6 digits), not CCYYMMDD (8 digits)
598        let data = Interchangedaten {
599            syntax_kennung: Some("UNOC".to_string()),
600            absender_code: Some("9900000000003".to_string()),
601            empfaenger_code: Some("9900000000001".to_string()),
602            datum: Some("20260409".to_string()), // 8-digit CCYYMMDD input
603            zeit: Some("0725".to_string()),
604            interchange_ref: Some("00004".to_string()),
605        };
606
607        let unb = rebuild_unb_from_interchangedaten(&data);
608        assert_eq!(unb.elements[3], vec!["260409", "0725"]); // normalized to 6-digit YYMMDD
609
610        // Same via JSON path
611        let nd = serde_json::json!({
612            "syntaxKennung": "UNOC",
613            "absenderCode": "9900000000003",
614            "empfaengerCode": "9900000000001",
615            "datum": "20260409",
616            "zeit": "0725",
617            "interchangeRef": "00004"
618        });
619        let unb_json = rebuild_unb(&nd);
620        assert_eq!(unb_json.elements[3], vec!["260409", "0725"]);
621    }
622
623    #[test]
624    fn test_rebuild_unb_preserves_yymmdd() {
625        // Already 6-digit YYMMDD — should pass through unchanged
626        let data = Interchangedaten {
627            datum: Some("260409".to_string()),
628            zeit: Some("0725".to_string()),
629            ..Default::default()
630        };
631        let unb = rebuild_unb_from_interchangedaten(&data);
632        assert_eq!(unb.elements[3], vec!["260409", "0725"]);
633    }
634
635    #[test]
636    fn test_into_dynamic_nachricht() {
637        let mapped = MappedMessage {
638            stammdaten: serde_json::json!({"marktteilnehmer": []}),
639            transaktionen: vec![MappedTransaktion {
640                stammdaten: serde_json::json!({"prozessdaten": {"id": "1"}}),
641                nesting_info: Default::default(),
642                dp_routing: Default::default(),
643            }],
644            nesting_info: Default::default(),
645            dp_routing: Default::default(),
646            inter_group_segments: Default::default(),
647        };
648
649        let nd = Nachrichtendaten {
650            unh_referenz: "00001".to_string(),
651            nachrichten_typ: "UTILMD".to_string(),
652        };
653
654        let nachricht = mapped.into_dynamic_nachricht(nd);
655        assert_eq!(nachricht.nachrichtendaten.unh_referenz, "00001");
656        assert_eq!(nachricht.transaktionen.len(), 1);
657        assert!(nachricht.transaktionen[0]["prozessdaten"].is_object());
658    }
659}