edifact-mapper 0.8.1

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! The five `RFF` references of a `TechnischeRessource` SG8 are five different
//! facts, so they need five different BO4E fields.
//!
//! Until this test, 88 mapping positions sent `RFF+Z16`, `RFF+Z32`, `RFF+Z34`,
//! `RFF+Z37` and `RFF+Z38` to one `referenz.identifikator`. Forward that is a
//! lossy funnel (the last qualifier wins); reverse it is worse, because every
//! one of those rules reads the same field, so `to_edifact` writes the *same*
//! value into every RFF the definition declares. The bulk roundtrips did not
//! notice: the generated fixtures fill a group's RFFs with one placeholder
//! value, so funnelling five identical values through one field and fanning
//! them back out is byte-identical.
//!
//! Four of the five now write the field the AHB meaning of their qualifier
//! names (the PID schema's own segment texts): `Z34` "vorgelagerte
//! Messlokation" → `vorgelagerteMesslokationId`, `Z16` "zugeordnete
//! Marktlokation" → `zugeordneteMarktlokationId`, `Z38` "Steuerbare Ressource"
//! → `steuerbareRessourceRef`, `Z32` "Netzlokation" → `netzlokationRef` — the
//! assignment the tree already used for the same qualifiers in 55617/55623 SG8
//! and in the 55035/55060 SG8 ZD6 files.
//!
//! `Z37` "Technische Ressource" keeps the `referenz` Identifikation COM rather
//! than taking the name-matching `technischeRessourceId`, because that field is
//! already the canonical home of a *different* EDIFACT element: the SG5
//! `LOC+Z20` identification (`common/sg5_z20.toml`), merged onto the same
//! entity. Sharing one field made the reverse write the SG5 value into the SG8
//! `RFF+Z37` — `corpus_typed_hop_roundtrip_test` caught exactly that for
//! 55035/55060/55095 when this change first tried it. One EDIFACT element, one
//! home (#83); five qualifiers, five destinations either way.
//!
//! So this test gives two of them *different* values and requires both to
//! survive a reverse that starts from BO4E JSON alone. On the funnelled mapping
//! it cannot pass, whichever way the funnel resolves.

use std::path::PathBuf;

use edifact_mapper::{DataDir, EdifactParty, InterchangeEnvelope, InterchangeMessage, Mapper};
use serde_json::Value;

type Dynamic = mig_bo4e::model::Interchange<Value, Value>;

const FV: &str = "FV2504";
const VARIANT: &str = "UTILMD_Strom";
/// 55629 is the smallest PID whose SG8 `SEQ+Z52` carries two of the five
/// references (`RFF+Z38` and `RFF+Z32`), and its generated fixture has both.
const PID: &str = "55629";

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|p| p.parent())
        .expect("crate is two levels under the workspace root")
        .to_path_buf()
}

fn setup() -> Option<(Mapper, String)> {
    let root = repo_root();
    let fixture = root.join(format!(
        "fixtures/generated/{}/utilmd/{PID}.edi",
        FV.to_lowercase()
    ));
    if !root.join(format!("dist/edifact-data-{FV}.bin")).exists() || !fixture.exists() {
        eprintln!("skipping: regenerate the {FV} bundle + fixture first");
        return None;
    }
    let mapper =
        Mapper::from_data_dir(DataDir::path(root.join("dist")).eager(&[FV])).expect("load bundle");
    Some((
        mapper,
        std::fs::read_to_string(&fixture).expect("read fixture"),
    ))
}

fn render(mapper: &Mapper, ic: &Dynamic) -> String {
    let msg = &ic.nachrichten[0];
    mapper
        .to_edifact_interchange(
            &InterchangeEnvelope {
                sender: EdifactParty::bdew("9900000000001"),
                receiver: EdifactParty::bdew("9900000000002"),
                interchange_ref: "1".to_string(),
            },
            &[InterchangeMessage {
                message_ref: "1".to_string(),
                msg_stammdaten: msg.stammdaten.clone(),
                tx_stammdaten: msg.transaktionen.clone(),
                fv: FV.to_string(),
                variant: VARIANT.to_string(),
                pid: PID.to_string(),
            }],
        )
        .expect("render")
}

fn segments(edifact: &str) -> Vec<String> {
    edifact
        .split('\'')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .collect()
}

/// The entity map of a transaction, which is handed out as
/// `{stammdaten, transaktionsdaten}`.
fn entities_mut(tx: &mut Value) -> &mut Value {
    if tx.get("stammdaten").is_some() {
        &mut tx["stammdaten"]
    } else {
        tx
    }
}

/// The `technischeRessourceDaten` object of the SG8 `SEQ+Z52` repetition — the
/// one carrying the references. ("Daten der Technischen Ressource" is its own
/// entity, off the SG5 `LOC+Z20` it repeats under.) The key is an object for a
/// single repetition and an array when the PID has several.
fn technische_ressource_mut(tx: &mut Value) -> &mut Value {
    let entity = entities_mut(tx)
        .get_mut("technischeRessourceDaten")
        .expect("the fixture has a technischeRessourceDaten");
    match entity {
        Value::Array(reps) => reps
            .iter_mut()
            .find(|rep| rep.get("steuerbareRessourceRef").is_some())
            .expect("a repetition carrying the SG8 references"),
        other => other,
    }
}

#[test]
fn two_reference_qualifiers_keep_their_own_values_through_a_reverse() {
    let Some((mapper, edifact)) = setup() else {
        return;
    };
    let mut ic: Dynamic = mapper
        .from_edifact(&edifact, FV, VARIANT, PID)
        .expect("parse fixture");

    // Forward: each qualifier landed in its own field. Before the split both
    // were one `referenz.identifikator`, so this alone is the forward half of
    // the regression.
    {
        let tr = technische_ressource_mut(&mut ic.nachrichten[0].transaktionen[0]);
        assert!(
            tr.get("steuerbareRessourceRef").is_some() && tr.get("netzlokationRef").is_some(),
            "RFF+Z38 and RFF+Z32 must each have their own field, got: {tr:#}"
        );
        // The fixture fills both RFFs with the *same* placeholder, which is
        // precisely why every bulk roundtrip stayed green over the funnel. The
        // distinct values below are what the funnel cannot represent.
    }

    // Reverse from JSON alone, with the two references deliberately different —
    // the case a single funnel field cannot represent.
    const STEUERBARE_RESSOURCE: &str = "SR99999999991";
    const NETZLOKATION: &str = "NL99999999992";
    {
        let tr = technische_ressource_mut(&mut ic.nachrichten[0].transaktionen[0]);
        tr["steuerbareRessourceRef"] = Value::String(STEUERBARE_RESSOURCE.to_string());
        tr["netzlokationRef"] = Value::String(NETZLOKATION.to_string());
    }

    let rendered = render(&mapper, &ic);
    let segs = segments(&rendered);
    assert!(
        segs.contains(&format!("RFF+Z38:{STEUERBARE_RESSOURCE}")),
        "the Steuerbare Ressource reference is missing or carries the wrong \
         value:\n{rendered}"
    );
    assert!(
        segs.contains(&format!("RFF+Z32:{NETZLOKATION}")),
        "the Netzlokation reference is missing or carries the wrong \
         value:\n{rendered}"
    );

    // And back again: the values must still be distinguishable after a full
    // hop, not merely present in the rendered text.
    let mut reparsed: Dynamic = mapper
        .from_edifact(&rendered, FV, VARIANT, PID)
        .expect("reparse rendered message");
    let tr = technische_ressource_mut(&mut reparsed.nachrichten[0].transaktionen[0]);
    assert_eq!(tr["steuerbareRessourceRef"], STEUERBARE_RESSOURCE);
    assert_eq!(tr["netzlokationRef"], NETZLOKATION);
}