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    is_wrapped_transaktion, DynamicInterchange, DynamicNachricht, DynamicTransaktion, Interchange,
17    Interchangedaten, Nachricht, Nachrichtendaten, Transaktion, Uebermittlungsabschnitt,
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/// Read the whole UNH into the message's metadata slot.
174///
175/// [`extract_unh_fields`] reads the two elements the pipeline has always
176/// needed — 0062 and S009/0065. The rest of the segment was dropped: 0068
177/// (Allgemeine Zuordnungs-Referenz) and the S010 pair that says which
178/// transmission of a split message this is. Fifty-three Pruefidentifikatoren
179/// require one of those, so a message that lost them no longer satisfied its
180/// own guide once it had been through BO4E (issue #166).
181pub fn extract_message_header(unh: &OwnedSegment) -> Nachrichtendaten {
182    let (unh_referenz, nachrichten_typ) = extract_unh_fields(unh);
183    let non_empty = |s: &str| (!s.is_empty()).then(|| s.to_string());
184    Nachrichtendaten {
185        unh_referenz,
186        nachrichten_typ,
187        zuordnungsreferenz: non_empty(unh.get_element(2)),
188        uebermittlungsfolgenummer: non_empty(unh.get_component(3, 0)),
189        uebermittlungsabschnitt: Uebermittlungsabschnitt::from_code(unh.get_component(3, 1)),
190        nachricht: Default::default(),
191    }
192}
193
194/// Extract typed interchange-level metadata from envelope segments (UNB).
195pub fn extract_interchangedaten(envelope: &[OwnedSegment]) -> Interchangedaten {
196    let mut result = Interchangedaten::default();
197
198    for seg in envelope {
199        if seg.is("UNB") {
200            let val = |s: &str| {
201                if s.is_empty() {
202                    None
203                } else {
204                    Some(s.to_string())
205                }
206            };
207            result.syntax_kennung = val(seg.get_component(0, 0));
208            result.absender_code = val(seg.get_component(1, 0));
209            result.empfaenger_code = val(seg.get_component(2, 0));
210            result.datum = val(seg.get_component(3, 0));
211            result.zeit = val(seg.get_component(3, 1));
212            result.interchange_ref = val(seg.get_element(4));
213        }
214    }
215
216    result
217}
218
219/// Extract interchange-level metadata from envelope segments (UNB) as JSON.
220///
221/// Kept for backward compatibility. Prefer `extract_interchangedaten()` for typed access.
222pub fn extract_nachrichtendaten(envelope: &[OwnedSegment]) -> serde_json::Value {
223    let data = extract_interchangedaten(envelope);
224    serde_json::to_value(&data).unwrap_or_default()
225}
226
227/// Normalize a date string to UNB S004 YYMMDD format (6 digits).
228///
229/// UNB with UNOC:3 syntax uses YYMMDD (6 digits), not CCYYMMDD (8 digits).
230/// If an 8-digit CCYYMMDD date is provided, the century prefix is stripped.
231fn normalize_unb_datum(datum: &str) -> &str {
232    if datum.len() == 8 && datum.as_bytes().iter().all(|b| b.is_ascii_digit()) {
233        &datum[2..]
234    } else {
235        datum
236    }
237}
238
239/// Rebuild a UNB (interchange header) segment from typed `Interchangedaten`.
240///
241/// This is the inverse of `extract_interchangedaten()`.
242/// Fields not present get sensible defaults (UNOC:3, "500" qualifier).
243/// Dates in CCYYMMDD (8-digit) format are automatically normalized to YYMMDD (6-digit).
244pub fn rebuild_unb_from_interchangedaten(data: &Interchangedaten) -> OwnedSegment {
245    let syntax = data.syntax_kennung.as_deref().unwrap_or("UNOC");
246    let sender = data.absender_code.as_deref().unwrap_or("");
247    let receiver = data.empfaenger_code.as_deref().unwrap_or("");
248    let datum = normalize_unb_datum(data.datum.as_deref().unwrap_or(""));
249    let zeit = data.zeit.as_deref().unwrap_or("");
250    let interchange_ref = data.interchange_ref.as_deref().unwrap_or("00000");
251
252    OwnedSegment {
253        id: "UNB".to_string(),
254        elements: vec![
255            vec![syntax.to_string(), "3".to_string()],
256            vec![sender.to_string(), "500".to_string()],
257            vec![receiver.to_string(), "500".to_string()],
258            vec![datum.to_string(), zeit.to_string()],
259            vec![interchange_ref.to_string()],
260        ],
261        segment_number: 0,
262    }
263}
264
265/// Rebuild a UNB (interchange header) segment from nachrichtendaten JSON.
266///
267/// This is the inverse of `extract_nachrichtendaten()`.
268/// Fields not present in the JSON get sensible defaults (UNOC:3, "500" qualifier).
269/// Dates in CCYYMMDD (8-digit) format are automatically normalized to YYMMDD (6-digit).
270pub fn rebuild_unb(nachrichtendaten: &serde_json::Value) -> OwnedSegment {
271    let syntax = nachrichtendaten
272        .get("syntaxKennung")
273        .and_then(|v| v.as_str())
274        .unwrap_or("UNOC");
275    let sender = nachrichtendaten
276        .get("absenderCode")
277        .and_then(|v| v.as_str())
278        .unwrap_or("");
279    let receiver = nachrichtendaten
280        .get("empfaengerCode")
281        .and_then(|v| v.as_str())
282        .unwrap_or("");
283    let datum_raw = nachrichtendaten
284        .get("datum")
285        .and_then(|v| v.as_str())
286        .unwrap_or("");
287    let datum = normalize_unb_datum(datum_raw);
288    let zeit = nachrichtendaten
289        .get("zeit")
290        .and_then(|v| v.as_str())
291        .unwrap_or("");
292    let interchange_ref = nachrichtendaten
293        .get("interchangeRef")
294        .and_then(|v| v.as_str())
295        .unwrap_or("00000");
296
297    OwnedSegment {
298        id: "UNB".to_string(),
299        elements: vec![
300            vec![syntax.to_string(), "3".to_string()],
301            vec![sender.to_string(), "500".to_string()],
302            vec![receiver.to_string(), "500".to_string()],
303            vec![datum.to_string(), zeit.to_string()],
304            vec![interchange_ref.to_string()],
305        ],
306        segment_number: 0,
307    }
308}
309
310/// Rebuild a UNH (message header) segment.
311///
312/// Produces `UNH+referenz+typ:D:{release}:UN:{association}`, followed by the
313/// optional 0068 and S010 elements when `header` carries them.
314///
315/// `release` (S009 d0054) and `association` (S009 d0057) are message-type and
316/// MIG-version specific — UTILMD Strom is `11A`/`S2.1`, UTILMD Gas `11A`/`G1.0a`,
317/// MSCONS `04B`/`2.4c`. Hardcoding them makes every reverse-rendered message fail
318/// the AHB code rule on S009 for every variant but UTILMD Strom, so callers pass
319/// the values from the MIG they mapped against
320/// ([`release_code_for_message_type`] and `MigSchema::version`).
321pub fn rebuild_unh(header: &Nachrichtendaten, release: &str, association: &str) -> OwnedSegment {
322    let mut elements = vec![
323        vec![header.unh_referenz.clone()],
324        vec![
325            header.nachrichten_typ.clone(),
326            "D".to_string(),
327            release.to_string(),
328            "UN".to_string(),
329            association.to_string(),
330        ],
331    ];
332
333    // 0068 and S010 are optional and trailing, so they are written only when
334    // the message carries them — and 0068 has to be written as an empty
335    // element when only S010 is present, because S010 is positional.
336    let s010: Vec<String> = match (
337        &header.uebermittlungsfolgenummer,
338        header.uebermittlungsabschnitt,
339    ) {
340        (None, None) => Vec::new(),
341        (folge, abschnitt) => vec![
342            folge.clone().unwrap_or_default(),
343            abschnitt.map(|a| a.code().to_string()).unwrap_or_default(),
344        ],
345    };
346    if header.zuordnungsreferenz.is_some() || !s010.is_empty() {
347        elements.push(vec![header.zuordnungsreferenz.clone().unwrap_or_default()]);
348    }
349    if !s010.is_empty() {
350        elements.push(s010);
351    }
352
353    OwnedSegment {
354        id: "UNH".to_string(),
355        elements,
356        segment_number: 0,
357    }
358}
359
360/// UN/EDIFACT directory release code (UNH S009 d0054) for a message type.
361///
362/// Pinned against the AHB's allowed codes by
363/// `edifact-mapper/tests/unh_release_codes.rs` — a wrong value here makes every
364/// generated message fail the code rule on UNH S009. The values are the same
365/// across all format versions the repo ships.
366pub fn release_code_for_message_type(msg_type: &str) -> &'static str {
367    match msg_type {
368        "APERAK" => "07B",
369        "COMDIS" => "17A",
370        // CONTRL is versioned by syntax level, not by a UN/EDIFACT directory.
371        "CONTRL" => "3",
372        "IFTSTA" => "18A",
373        "INSRPT" => "10A",
374        "INVOIC" => "06A",
375        "MSCONS" => "04B",
376        "ORDCHG" => "20B",
377        "ORDERS" => "09B",
378        "ORDRSP" => "10A",
379        "PARTIN" => "20B",
380        "PRICAT" => "20B",
381        "QUOTES" => "10A",
382        "REMADV" => "05A",
383        "REQOTE" => "10A",
384        "UTILMD" => "11A",
385        "UTILTS" => "18A",
386        _ => "04B", // fallback
387    }
388}
389
390/// Rebuild a UNT (message trailer) segment.
391///
392/// Produces: `UNT+count+referenz`
393/// `segment_count` includes UNH and UNT themselves.
394pub fn rebuild_unt(segment_count: usize, referenz: &str) -> OwnedSegment {
395    OwnedSegment {
396        id: "UNT".to_string(),
397        elements: vec![vec![segment_count.to_string()], vec![referenz.to_string()]],
398        segment_number: 0,
399    }
400}
401
402/// Rebuild a UNZ (interchange trailer) segment.
403///
404/// Produces: `UNZ+count+ref`
405pub fn rebuild_unz(message_count: usize, interchange_ref: &str) -> OwnedSegment {
406    OwnedSegment {
407        id: "UNZ".to_string(),
408        elements: vec![
409            vec![message_count.to_string()],
410            vec![interchange_ref.to_string()],
411        ],
412        segment_number: 0,
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn test_mapped_transaktion_serde_roundtrip() {
422        let tx = MappedTransaktion {
423            transaktionsdaten: serde_json::Value::Null,
424            stammdaten: serde_json::json!({
425                "prozessdaten": {
426                    "vorgangId": "TX001",
427                    "transaktionsgrund": "E01"
428                },
429                "marktlokation": { "marktlokationsId": "DE000111222333" }
430            }),
431            nesting_info: Default::default(),
432        };
433
434        let json = serde_json::to_string(&tx).unwrap();
435        let de: MappedTransaktion = serde_json::from_str(&json).unwrap();
436        assert_eq!(
437            de.stammdaten["prozessdaten"]["vorgangId"].as_str().unwrap(),
438            "TX001"
439        );
440        assert!(de.stammdaten["marktlokation"].is_object());
441    }
442
443    #[test]
444    fn test_dynamic_nachricht_serde_roundtrip() {
445        let msg: DynamicNachricht = Nachricht {
446            nachrichtendaten: Nachrichtendaten {
447                unh_referenz: "00001".to_string(),
448                nachrichten_typ: "UTILMD".to_string(),
449                nachricht: Default::default(),
450                ..Default::default()
451            },
452            stammdaten: serde_json::json!({
453                "marktteilnehmer": [
454                    { "marktrolle": "MS", "rollencodenummer": "9900123" }
455                ]
456            }),
457            transaktionen: vec![Transaktion {
458                transaktionsdaten: serde_json::Value::Null,
459                stammdaten: serde_json::json!({}),
460            }],
461        };
462
463        let json = serde_json::to_string(&msg).unwrap();
464        let de: DynamicNachricht = serde_json::from_str(&json).unwrap();
465        assert_eq!(de.nachrichtendaten.unh_referenz, "00001");
466        assert_eq!(de.nachrichtendaten.nachrichten_typ, "UTILMD");
467        assert_eq!(de.transaktionen.len(), 1);
468    }
469
470    #[test]
471    fn test_dynamic_interchange_serde_roundtrip() {
472        let interchange: DynamicInterchange = Interchange {
473            interchangedaten: Interchangedaten {
474                absender_code: Some("9900123456789".to_string()),
475                empfaenger_code: Some("9900987654321".to_string()),
476                ..Default::default()
477            },
478            nachrichten: vec![Nachricht {
479                nachrichtendaten: Nachrichtendaten {
480                    unh_referenz: "00001".to_string(),
481                    nachrichten_typ: "UTILMD".to_string(),
482                    nachricht: Default::default(),
483                    ..Default::default()
484                },
485                stammdaten: serde_json::json!({}),
486                transaktionen: vec![],
487            }],
488        };
489
490        let json = serde_json::to_string_pretty(&interchange).unwrap();
491        let de: DynamicInterchange = serde_json::from_str(&json).unwrap();
492        assert_eq!(de.nachrichten.len(), 1);
493        assert_eq!(de.nachrichten[0].nachrichtendaten.unh_referenz, "00001");
494    }
495
496    #[test]
497    fn test_extract_interchangedaten_from_segments() {
498        let envelope = vec![OwnedSegment {
499            id: "UNB".to_string(),
500            elements: vec![
501                vec!["UNOC".to_string(), "3".to_string()],
502                vec!["9900123456789".to_string(), "500".to_string()],
503                vec!["9900987654321".to_string(), "500".to_string()],
504                vec!["210101".to_string(), "1200".to_string()],
505                vec!["REF001".to_string()],
506            ],
507            segment_number: 0,
508        }];
509
510        let data = extract_interchangedaten(&envelope);
511        assert_eq!(data.absender_code.as_deref(), Some("9900123456789"));
512        assert_eq!(data.empfaenger_code.as_deref(), Some("9900987654321"));
513        assert_eq!(data.interchange_ref.as_deref(), Some("REF001"));
514        assert_eq!(data.syntax_kennung.as_deref(), Some("UNOC"));
515        assert_eq!(data.datum.as_deref(), Some("210101"));
516        assert_eq!(data.zeit.as_deref(), Some("1200"));
517    }
518
519    #[test]
520    fn test_extract_envelope_from_segments_json() {
521        let envelope = vec![OwnedSegment {
522            id: "UNB".to_string(),
523            elements: vec![
524                vec!["UNOC".to_string(), "3".to_string()],
525                vec!["9900123456789".to_string(), "500".to_string()],
526                vec!["9900987654321".to_string(), "500".to_string()],
527                vec!["210101".to_string(), "1200".to_string()],
528                vec!["REF001".to_string()],
529            ],
530            segment_number: 0,
531        }];
532
533        let nd = extract_nachrichtendaten(&envelope);
534        assert_eq!(nd["absenderCode"].as_str().unwrap(), "9900123456789");
535        assert_eq!(nd["empfaengerCode"].as_str().unwrap(), "9900987654321");
536        assert_eq!(nd["interchangeRef"].as_str().unwrap(), "REF001");
537        assert_eq!(nd["syntaxKennung"].as_str().unwrap(), "UNOC");
538        assert_eq!(nd["datum"].as_str().unwrap(), "210101");
539        assert_eq!(nd["zeit"].as_str().unwrap(), "1200");
540    }
541
542    #[test]
543    fn test_extract_unh_fields() {
544        let unh = OwnedSegment {
545            id: "UNH".to_string(),
546            elements: vec![
547                vec!["MSG001".to_string()],
548                vec![
549                    "UTILMD".to_string(),
550                    "D".to_string(),
551                    "11A".to_string(),
552                    "UN".to_string(),
553                    "S2.1".to_string(),
554                ],
555            ],
556            segment_number: 0,
557        };
558
559        let (referenz, typ) = extract_unh_fields(&unh);
560        assert_eq!(referenz, "MSG001");
561        assert_eq!(typ, "UTILMD");
562    }
563
564    #[test]
565    fn test_rebuild_unb_from_interchangedaten_typed() {
566        let data = Interchangedaten {
567            syntax_kennung: Some("UNOC".to_string()),
568            absender_code: Some("9900123456789".to_string()),
569            empfaenger_code: Some("9900987654321".to_string()),
570            datum: Some("210101".to_string()),
571            zeit: Some("1200".to_string()),
572            interchange_ref: Some("REF001".to_string()),
573        };
574
575        let unb = rebuild_unb_from_interchangedaten(&data);
576        assert_eq!(unb.id, "UNB");
577        assert_eq!(unb.elements[0], vec!["UNOC", "3"]);
578        assert_eq!(unb.elements[1][0], "9900123456789");
579        assert_eq!(unb.elements[2][0], "9900987654321");
580        assert_eq!(unb.elements[3], vec!["210101", "1200"]);
581        assert_eq!(unb.elements[4], vec!["REF001"]);
582    }
583
584    #[test]
585    fn test_rebuild_unb_from_nachrichtendaten() {
586        let nd = serde_json::json!({
587            "syntaxKennung": "UNOC",
588            "absenderCode": "9900123456789",
589            "empfaengerCode": "9900987654321",
590            "datum": "210101",
591            "zeit": "1200",
592            "interchangeRef": "REF001"
593        });
594
595        let unb = rebuild_unb(&nd);
596        assert_eq!(unb.id, "UNB");
597        assert_eq!(unb.elements[0], vec!["UNOC", "3"]);
598        assert_eq!(unb.elements[1][0], "9900123456789");
599        assert_eq!(unb.elements[2][0], "9900987654321");
600        assert_eq!(unb.elements[3], vec!["210101", "1200"]);
601        assert_eq!(unb.elements[4], vec!["REF001"]);
602    }
603
604    #[test]
605    fn test_rebuild_unb_defaults() {
606        let nd = serde_json::json!({});
607        let unb = rebuild_unb(&nd);
608        assert_eq!(unb.id, "UNB");
609        assert_eq!(unb.elements[0], vec!["UNOC", "3"]);
610    }
611
612    /// A message header carrying only the two elements every message has.
613    fn header(referenz: &str, typ: &str) -> Nachrichtendaten {
614        Nachrichtendaten {
615            unh_referenz: referenz.to_string(),
616            nachrichten_typ: typ.to_string(),
617            ..Default::default()
618        }
619    }
620
621    /// The whole UNH survives the hop, not just 0062 and S009.
622    ///
623    /// 0068 and S010 were dropped on the way in and never rebuilt on the way
624    /// out, so 53 Pruefidentifikatoren lost a field their own guide requires
625    /// (issue #166).
626    #[test]
627    fn the_optional_unh_elements_survive_extraction_and_rebuild() {
628        let original = OwnedSegment {
629            id: "UNH".to_string(),
630            elements: vec![
631                vec!["GENERATED00001".to_string()],
632                vec![
633                    "MSCONS".to_string(),
634                    "D".to_string(),
635                    "04B".to_string(),
636                    "UN".to_string(),
637                    "2.4c".to_string(),
638                ],
639                vec!["ZUORDNUNG1".to_string()],
640                vec!["3".to_string(), "C".to_string()],
641            ],
642            segment_number: 0,
643        };
644
645        let header = extract_message_header(&original);
646        assert_eq!(header.zuordnungsreferenz.as_deref(), Some("ZUORDNUNG1"));
647        assert_eq!(header.uebermittlungsfolgenummer.as_deref(), Some("3"));
648        assert_eq!(
649            header.uebermittlungsabschnitt,
650            Some(Uebermittlungsabschnitt::Beginn),
651            "the BO4E carries the MIG's name for C, not the code"
652        );
653
654        let rebuilt = rebuild_unh(&header, "04B", "2.4c");
655        assert_eq!(rebuilt.elements, original.elements);
656    }
657
658    /// A message without them renders the two-element UNH it always did.
659    #[test]
660    fn a_header_without_the_optional_elements_renders_as_before() {
661        let unh = rebuild_unh(&header("00001", "UTILMD"), "11A", "S2.1");
662        assert_eq!(unh.elements.len(), 2, "no empty trailing elements: {unh:?}");
663    }
664
665    /// S010 is positional, so 0068 has to hold its place when it is absent.
666    #[test]
667    fn s010_without_0068_keeps_its_position() {
668        let mut h = header("00001", "UTILMD");
669        h.uebermittlungsfolgenummer = Some("2".to_string());
670        let unh = rebuild_unh(&h, "11A", "S2.1");
671        assert_eq!(unh.elements[2], vec![""], "0068 holds S010's position");
672        assert_eq!(unh.elements[3], vec!["2", ""]);
673    }
674
675    #[test]
676    fn test_rebuild_unh() {
677        let unh = rebuild_unh(&header("00001", "UTILMD"), "11A", "S2.1");
678        assert_eq!(unh.id, "UNH");
679        assert_eq!(unh.elements[0], vec!["00001"]);
680        assert_eq!(unh.elements[1][0], "UTILMD");
681        assert_eq!(unh.elements[1][1], "D");
682        assert_eq!(unh.elements[1][2], "11A");
683        assert_eq!(unh.elements[1][3], "UN");
684        assert_eq!(unh.elements[1][4], "S2.1");
685    }
686
687    #[test]
688    fn test_rebuild_unh_uses_the_given_release_and_association() {
689        // UTILMD Gas rides on the same D:11A directory but a different MIG
690        // version; hardcoding S2.1 fails the AHB code rule on UNH S009.
691        let unh = rebuild_unh(&header("00001", "UTILMD"), "11A", "G1.0a");
692        assert_eq!(unh.elements[1], vec!["UTILMD", "D", "11A", "UN", "G1.0a"]);
693
694        let unh = rebuild_unh(
695            &header("00001", "MSCONS"),
696            release_code_for_message_type("MSCONS"),
697            "2.4c",
698        );
699        assert_eq!(unh.elements[1], vec!["MSCONS", "D", "04B", "UN", "2.4c"]);
700    }
701
702    #[test]
703    fn test_rebuild_unt() {
704        let unt = rebuild_unt(25, "00001");
705        assert_eq!(unt.id, "UNT");
706        assert_eq!(unt.elements[0], vec!["25"]);
707        assert_eq!(unt.elements[1], vec!["00001"]);
708    }
709
710    #[test]
711    fn test_rebuild_unz() {
712        let unz = rebuild_unz(1, "REF001");
713        assert_eq!(unz.id, "UNZ");
714        assert_eq!(unz.elements[0], vec!["1"]);
715        assert_eq!(unz.elements[1], vec!["REF001"]);
716    }
717
718    #[test]
719    fn test_roundtrip_interchangedaten_rebuild() {
720        let original = OwnedSegment {
721            id: "UNB".to_string(),
722            elements: vec![
723                vec!["UNOC".to_string(), "3".to_string()],
724                vec!["9900123456789".to_string(), "500".to_string()],
725                vec!["9900987654321".to_string(), "500".to_string()],
726                vec!["210101".to_string(), "1200".to_string()],
727                vec!["REF001".to_string()],
728            ],
729            segment_number: 0,
730        };
731
732        let data = extract_interchangedaten(&[original]);
733        let rebuilt = rebuild_unb_from_interchangedaten(&data);
734        assert_eq!(rebuilt.elements[0], vec!["UNOC", "3"]);
735        assert_eq!(rebuilt.elements[1][0], "9900123456789");
736        assert_eq!(rebuilt.elements[2][0], "9900987654321");
737        assert_eq!(rebuilt.elements[3], vec!["210101", "1200"]);
738        assert_eq!(rebuilt.elements[4], vec!["REF001"]);
739    }
740
741    #[test]
742    fn test_roundtrip_nachrichtendaten_rebuild() {
743        let original = OwnedSegment {
744            id: "UNB".to_string(),
745            elements: vec![
746                vec!["UNOC".to_string(), "3".to_string()],
747                vec!["9900123456789".to_string(), "500".to_string()],
748                vec!["9900987654321".to_string(), "500".to_string()],
749                vec!["210101".to_string(), "1200".to_string()],
750                vec!["REF001".to_string()],
751            ],
752            segment_number: 0,
753        };
754
755        let nd = extract_nachrichtendaten(&[original]);
756        let rebuilt = rebuild_unb(&nd);
757        assert_eq!(rebuilt.elements[0], vec!["UNOC", "3"]);
758        assert_eq!(rebuilt.elements[1][0], "9900123456789");
759        assert_eq!(rebuilt.elements[2][0], "9900987654321");
760        assert_eq!(rebuilt.elements[3], vec!["210101", "1200"]);
761        assert_eq!(rebuilt.elements[4], vec!["REF001"]);
762    }
763
764    #[test]
765    fn test_rebuild_unb_normalizes_ccyymmdd_to_yymmdd() {
766        // UNB S004 datum must be YYMMDD (6 digits), not CCYYMMDD (8 digits)
767        let data = Interchangedaten {
768            syntax_kennung: Some("UNOC".to_string()),
769            absender_code: Some("9900000000003".to_string()),
770            empfaenger_code: Some("9900000000001".to_string()),
771            datum: Some("20260409".to_string()), // 8-digit CCYYMMDD input
772            zeit: Some("0725".to_string()),
773            interchange_ref: Some("00004".to_string()),
774        };
775
776        let unb = rebuild_unb_from_interchangedaten(&data);
777        assert_eq!(unb.elements[3], vec!["260409", "0725"]); // normalized to 6-digit YYMMDD
778
779        // Same via JSON path
780        let nd = serde_json::json!({
781            "syntaxKennung": "UNOC",
782            "absenderCode": "9900000000003",
783            "empfaengerCode": "9900000000001",
784            "datum": "20260409",
785            "zeit": "0725",
786            "interchangeRef": "00004"
787        });
788        let unb_json = rebuild_unb(&nd);
789        assert_eq!(unb_json.elements[3], vec!["260409", "0725"]);
790    }
791
792    #[test]
793    fn test_rebuild_unb_preserves_yymmdd() {
794        // Already 6-digit YYMMDD — should pass through unchanged
795        let data = Interchangedaten {
796            datum: Some("260409".to_string()),
797            zeit: Some("0725".to_string()),
798            ..Default::default()
799        };
800        let unb = rebuild_unb_from_interchangedaten(&data);
801        assert_eq!(unb.elements[3], vec!["260409", "0725"]);
802    }
803
804    #[test]
805    fn test_into_dynamic_nachricht() {
806        let mapped = MappedMessage {
807            nachricht_meta: serde_json::Value::Null,
808            stammdaten: serde_json::json!({"marktteilnehmer": []}),
809            transaktionen: vec![MappedTransaktion {
810                transaktionsdaten: serde_json::json!({"vorgangId": "1"}),
811                stammdaten: serde_json::json!({}),
812                nesting_info: Default::default(),
813            }],
814            nesting_info: Default::default(),
815            inter_group_segments: Default::default(),
816        };
817
818        let nd = Nachrichtendaten {
819            unh_referenz: "00001".to_string(),
820            nachrichten_typ: "UTILMD".to_string(),
821            nachricht: Default::default(),
822            ..Default::default()
823        };
824
825        let nachricht = mapped.into_dynamic_nachricht(nd);
826        assert_eq!(nachricht.nachrichtendaten.unh_referenz, "00001");
827        assert_eq!(nachricht.transaktionen.len(), 1);
828        // The transaction's metadata now sits in its own slot, not among the BOs.
829        assert_eq!(
830            nachricht.transaktionen[0].transaktionsdaten["vorgangId"],
831            "1"
832        );
833    }
834}