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