bo4e-edifact-types 0.30.14

Generic interchange model types for BO4E/EDIFACT conversion
Documentation
use serde::{Deserialize, Serialize};

/// EDIFACT interchange envelope metadata (UNB/UNZ).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Interchangedaten {
    pub syntax_kennung: Option<String>,
    pub absender_code: Option<String>,
    pub empfaenger_code: Option<String>,
    pub datum: Option<String>,
    pub zeit: Option<String>,
    pub interchange_ref: Option<String>,
    /// UNB S002 0007: the sender's code qualifier (`500` BDEW, `14` GS1).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub absender_qualifier: Option<String>,
    /// UNB S003 0007: the receiver's code qualifier.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub empfaenger_qualifier: Option<String>,
    /// UNB 0026: the Anwendungsreferenz (MSCONS: `VL`, `TL`, `EM`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub anwendungsreferenz: Option<String>,
}

/// Message-level metadata: the UNH/UNT envelope plus the BGM/DTM/FTX document
/// fields the mappings model as the `Nachricht` entity.
///
/// One metadata slot per level is the shape: `Interchangedaten` for the UNB/UNZ
/// interchange, this for the message, `transaktionsdaten` for each transaction.
/// `Nachricht` is metadata *about* the message rather than a business object the
/// message is about, so it belongs here and not among the BOs in `stammdaten`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Nachrichtendaten {
    pub unh_referenz: String,
    pub nachrichten_typ: String,

    /// UNH 0068 — Allgemeine Zuordnungs-Referenz.
    ///
    /// A free-text reference the sender may attach to the message. The AHB
    /// marks it `Soll` on 53 Pruefidentifikatoren, so a message that loses it
    /// no longer satisfies its own guide.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub zuordnungsreferenz: Option<String>,

    /// UNH S010/0070 — Übermittlungsfolgenummer.
    ///
    /// Which transmission this is when one logical message is split across
    /// several. Named "Laufende Nummer bei Aufteilung von Nachrichten" in the
    /// UTILMD guides and "Übermittlungsfolgenummer" in MSCONS; same element.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uebermittlungsfolgenummer: Option<String>,

    /// UNH S010/0073 — Erste und letzte Übermittlung.
    ///
    /// Marks the first or the last transmission of a split message. The wire
    /// carries `C`/`F`; BO4E carries the MIG's own names for them.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uebermittlungsabschnitt: Option<Uebermittlungsabschnitt>,

    /// BGM/DTM/FTX document metadata, flattened onto this object so the wire
    /// carries one object per level rather than a nested pair.
    ///
    /// Held as a map, not as a typed `bo4e_mako::com::Nachricht`: the engine is
    /// data-driven (see #92), so deserializing here would mean a fallible
    /// conversion in the middle of the pipeline whose only honest failure
    /// handling is to drop the fields. The typed view is proven instead by the
    /// typed-hop gates, which cannot silently lose anything.
    #[serde(flatten, default)]
    pub nachricht: serde_json::Map<String, serde_json::Value>,
}

/// UNH S010/0073 — which end of a split message this transmission is.
///
/// The names are the MIG's (`Beginn`, `Ende`); the wire values are the EDIFACT
/// codes they stand for. Keeping the pair here is what lets the BO4E carry the
/// name while the reverse direction still writes the code.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Uebermittlungsabschnitt {
    Beginn,
    Ende,
}

impl Uebermittlungsabschnitt {
    /// The EDIFACT code for this value.
    pub fn code(self) -> &'static str {
        match self {
            Self::Beginn => "C",
            Self::Ende => "F",
        }
    }

    /// The value an EDIFACT code stands for, or `None` for a code the MIG does
    /// not define — which the forward direction keeps out of the BO4E rather
    /// than inventing a name for.
    pub fn from_code(code: &str) -> Option<Self> {
        match code {
            "C" => Some(Self::Beginn),
            "F" => Some(Self::Ende),
            _ => None,
        }
    }
}

/// A complete EDIFACT interchange. M = message stammdaten, T = transaction stammdaten.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Interchange<M, T> {
    pub interchangedaten: Interchangedaten,
    pub nachrichten: Vec<Nachricht<M, T>>,
}

/// A single EDIFACT message within an interchange.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Nachricht<M, T> {
    pub nachrichtendaten: Nachrichtendaten,
    pub stammdaten: M,
    pub transaktionen: Vec<T>,
}

/// One transaction: the business objects it is about, and the metadata about
/// the transaction itself.
///
/// `D` is the transaction metadata (the `Prozessdaten` entity), `S` the
/// business objects. Splitting them is what gives every level the same shape —
/// interchange, message and transaction each carry their own metadata slot
/// beside their content.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Transaktion<D, S> {
    /// Defaulted so `{"stammdaten": …}` alone deserializes: a transaction that
    /// carries no process data is normal, and rejecting it would make the
    /// metadata slot mandatory on input when it is not mandatory in the data.
    #[serde(default)]
    pub transaktionsdaten: D,
    pub stammdaten: S,
}

/// Whether this JSON is a [`Transaktion`] rather than a bare entity map.
///
/// Callers may hand a transaction over in either shape: the current
/// `{transaktionsdaten, stammdaten}` wrapper, or the bare entity map used
/// before the metadata slot existed. Both keys of the wrapper are optional in
/// practice — `transaktionsdaten` is absent when a transaction carries no
/// process data, and `stammdaten` when it carries no business objects — so
/// either key alone identifies the wrapper.
///
/// A transaction of metadata alone is not a curiosity: UTILMD 55044 rejecting
/// an Anmeldung with `ZC9` ("keine Zuordnung möglich") states a reason and
/// nothing else, because a rejection that could not identify the Messlokation
/// must not name one. Reading that as a bare entity map buries the metadata
/// under an entity name no mapping defines and renders an empty message
/// (issue #153), so the test asks about both keys, not just `stammdaten`.
pub fn is_wrapped_transaktion(tx: &serde_json::Value) -> bool {
    tx.get("stammdaten").is_some() || tx.get("transaktionsdaten").is_some()
}

/// Dynamic variant for untyped transaction usage.
pub type DynamicTransaktion = Transaktion<serde_json::Value, serde_json::Value>;
/// Dynamic variant for untyped usage.
pub type DynamicInterchange = Interchange<serde_json::Value, DynamicTransaktion>;
/// Dynamic variant for untyped message usage.
pub type DynamicNachricht = Nachricht<serde_json::Value, DynamicTransaktion>;

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn test_dynamic_interchange_serde_roundtrip() {
        let interchange: DynamicInterchange = Interchange {
            interchangedaten: Interchangedaten {
                syntax_kennung: Some("UNOC".to_string()),
                absender_code: Some("1234567890123".to_string()),
                empfaenger_code: Some("9876543210987".to_string()),
                datum: Some("260326".to_string()),
                zeit: Some("1430".to_string()),
                interchange_ref: Some("00001".to_string()),
                absender_qualifier: None,
                empfaenger_qualifier: None,
                anwendungsreferenz: None,
            },
            nachrichten: vec![Nachricht {
                nachrichtendaten: Nachrichtendaten {
                    unh_referenz: "00001".to_string(),
                    nachrichten_typ: "UTILMD".to_string(),
                    nachricht: Default::default(),
                    ..Default::default()
                },
                stammdaten: json!({"key": "value"}),
                transaktionen: vec![
                    Transaktion {
                        transaktionsdaten: json!({"vorgangId": "1"}),
                        stammdaten: json!({"tx": 1}),
                    },
                    Transaktion {
                        transaktionsdaten: json!({"vorgangId": "2"}),
                        stammdaten: json!({"tx": 2}),
                    },
                ],
            }],
        };

        let json_str = serde_json::to_string(&interchange).unwrap();
        let deserialized: DynamicInterchange = serde_json::from_str(&json_str).unwrap();

        assert_eq!(
            deserialized.interchangedaten.syntax_kennung,
            Some("UNOC".to_string())
        );
        assert_eq!(
            deserialized.interchangedaten.absender_code,
            Some("1234567890123".to_string())
        );
        assert_eq!(
            deserialized.interchangedaten.empfaenger_code,
            Some("9876543210987".to_string())
        );
        assert_eq!(deserialized.nachrichten.len(), 1);
        assert_eq!(
            deserialized.nachrichten[0].nachrichtendaten.unh_referenz,
            "00001"
        );
        assert_eq!(
            deserialized.nachrichten[0].nachrichtendaten.nachrichten_typ,
            "UTILMD"
        );
        assert_eq!(
            deserialized.nachrichten[0].stammdaten,
            json!({"key": "value"})
        );
        assert_eq!(deserialized.nachrichten[0].transaktionen.len(), 2);
    }

    #[test]
    fn test_typed_interchange_serde_roundtrip() {
        #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
        struct TestStamm {
            name: String,
        }

        #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
        struct TestTx {
            id: u32,
        }

        let interchange: Interchange<TestStamm, TestTx> = Interchange {
            interchangedaten: Interchangedaten::default(),
            nachrichten: vec![Nachricht {
                nachrichtendaten: Nachrichtendaten {
                    unh_referenz: "REF1".to_string(),
                    nachrichten_typ: "ORDERS".to_string(),
                    nachricht: Default::default(),
                    ..Default::default()
                },
                stammdaten: TestStamm {
                    name: "test".to_string(),
                },
                transaktionen: vec![TestTx { id: 1 }, TestTx { id: 2 }],
            }],
        };

        let json_str = serde_json::to_string(&interchange).unwrap();
        let deserialized: Interchange<TestStamm, TestTx> = serde_json::from_str(&json_str).unwrap();

        assert_eq!(deserialized.nachrichten.len(), 1);
        assert_eq!(
            deserialized.nachrichten[0].stammdaten,
            TestStamm {
                name: "test".to_string()
            }
        );
        assert_eq!(deserialized.nachrichten[0].transaktionen.len(), 2);
        assert_eq!(
            deserialized.nachrichten[0].transaktionen[0],
            TestTx { id: 1 }
        );
        assert_eq!(
            deserialized.nachrichten[0].transaktionen[1],
            TestTx { id: 2 }
        );
    }

    #[test]
    fn test_interchangedaten_camel_case_serialization() {
        let data = Interchangedaten {
            syntax_kennung: Some("UNOC".to_string()),
            absender_code: Some("SENDER".to_string()),
            empfaenger_code: None,
            datum: None,
            zeit: None,
            interchange_ref: Some("REF".to_string()),
            absender_qualifier: None,
            empfaenger_qualifier: None,
            anwendungsreferenz: None,
        };

        let json_value: serde_json::Value = serde_json::to_value(&data).unwrap();
        let obj = json_value.as_object().unwrap();

        // Verify camelCase keys
        assert!(obj.contains_key("syntaxKennung"));
        assert!(obj.contains_key("absenderCode"));
        assert!(obj.contains_key("empfaengerCode"));
        assert!(obj.contains_key("datum"));
        assert!(obj.contains_key("zeit"));
        assert!(obj.contains_key("interchangeRef"));

        // Verify no snake_case keys
        assert!(!obj.contains_key("syntax_kennung"));
        assert!(!obj.contains_key("absender_code"));
        assert!(!obj.contains_key("empfaenger_code"));
        assert!(!obj.contains_key("interchange_ref"));
    }
}