edifact-mapper 0.8.0

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! A segment group must never be rendered without its entry segment (#103).
//!
//! The entry segment opens a group repetition (CCI for SG10, SEQ for SG8, ...).
//! A BO4E object that fills a group's other fields but not the one its entry
//! segment is built from used to render e.g. `SEQ+Z03` `CAV+Z30:::X` without the
//! `CCI` — a message whose SG10 no receiver can assemble, so the Zähler data
//! was silently lost when the message was parsed again.
//!
//! Two layers keep that from happening:
//! * mappings default an entry-segment code the AHB allows exactly one value
//!   for, so the segment is written whenever the group has data;
//! * where no such default exists, rendering fails with
//!   [`MapperError::MissingGroupEntrySegment`] instead of emitting the group.

use std::path::PathBuf;

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

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

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()
}

/// Load the FV2604 bundle and a generated UTILMD fixture, or `None` to skip.
fn setup(pid: &str) -> Option<(Mapper, String)> {
    let root = repo_root();
    let fixture = root.join(format!("fixtures/generated/fv2604/utilmd/{pid}.edi"));
    if !root.join("dist/edifact-data-FV2604.bin").exists() || !fixture.exists() {
        eprintln!("skipping: regenerate the FV2604 bundle + fixture first");
        return None;
    }
    let mapper = Mapper::from_data_dir(DataDir::path(root.join("dist"))).expect("load bundle");
    let edifact = std::fs::read_to_string(&fixture).expect("read fixture");
    Some((mapper, edifact))
}

fn render(mapper: &Mapper, ic: &Dynamic, pid: &str) -> Result<String, MapperError> {
    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: "FV2604".to_string(),
            variant: "UTILMD_Strom".to_string(),
            pid: pid.to_string(),
        }],
    )
}

fn segments(edifact: &str) -> Vec<&str> {
    edifact.split('\'').map(str::trim).collect()
}

/// A transaction is handed out as `{stammdaten, transaktionsdaten}`; the entity
/// map these tests read and edit is the `stammdaten` half.
fn entities(tx: &serde_json::Value) -> &serde_json::Value {
    tx.get("stammdaten").unwrap_or(tx)
}

fn entities_mut(tx: &mut serde_json::Value) -> &mut serde_json::Value {
    if tx.get("stammdaten").is_some() {
        &mut tx["stammdaten"]
    } else {
        tx
    }
}

fn geschaeftspartner_count(tx: &serde_json::Value) -> usize {
    match tx.get("geschaeftspartner") {
        Some(serde_json::Value::Array(a)) => a.len(),
        Some(_) => 1,
        None => 0,
    }
}

/// The reproduction from #103: a `zaehler` without `zaehlertypMerkmal`. The
/// PID's AHB allows only `E13` there, so the mapping defaults it and the CCI is
/// written; the message parses back with the Zähler and every following NAD.
///
/// BO4E carries that code under the name the guide gives it, `zaehlertyp`; the
/// EDIFACT assertions below stay on `E13`, which is what the wire carries.
#[test]
fn zaehler_without_zaehlertyp_still_renders_its_cci_55042() {
    let Some((mapper, edifact)) = setup("55042") else {
        return;
    };
    let mut ic: Dynamic = mapper
        .from_edifact(&edifact, "FV2604", "UTILMD_Strom", "55042")
        .expect("parse fixture");
    let tx = entities_mut(&mut ic.nachrichten[0].transaktionen[0]);
    assert_eq!(tx["zaehler"]["zaehlertypMerkmal"], "zaehlertyp");
    // The fixture's device number is generated data, not a constant.
    let geraete_nummer = tx["zaehler"]["geraeteNummer"]
        .as_str()
        .expect("zaehler.geraeteNummer")
        .to_string();
    assert!(!geraete_nummer.is_empty());
    assert_eq!(geschaeftspartner_count(tx), 4);

    tx["zaehler"]
        .as_object_mut()
        .unwrap()
        .remove("zaehlertypMerkmal");

    let rendered = render(&mapper, &ic, "55042").expect("render without zaehlertypMerkmal");
    let segs = segments(&rendered);
    let seq = segs.iter().position(|s| *s == "SEQ+Z03").expect("SEQ+Z03");
    assert_eq!(
        &segs[seq..seq + 3],
        &[
            "SEQ+Z03",
            "CCI+++E13",
            format!("CAV+Z30:::{geraete_nummer}").as_str()
        ],
        "SG10 must open with its CCI:\n{rendered}"
    );

    let (reparsed, diagnostics): (Dynamic, _) = mapper
        .from_edifact_with_diagnostics(&rendered, "FV2604", "UTILMD_Strom", "55042")
        .expect("reparse");
    assert!(diagnostics.is_empty(), "{diagnostics:?}");
    let tx = entities(&reparsed.nachrichten[0].transaktionen[0]);
    assert_eq!(tx["zaehler"]["geraeteNummer"], geraete_nummer);
    assert_eq!(tx["zaehler"]["zaehlertypMerkmal"], "zaehlertyp");
    assert_eq!(geschaeftspartner_count(tx), 4);
}

/// Where the entry segment's code cannot be defaulted (the AHB allows several),
/// a group whose entry fields are missing is refused, naming the group, the
/// missing segment and the BO4E fields to fill.
#[test]
fn group_without_entry_segment_fields_is_refused_55071() {
    let Some((mapper, edifact)) = setup("55071") else {
        return;
    };
    let mut ic: Dynamic = mapper
        .from_edifact(&edifact, "FV2604", "UTILMD_Strom", "55071")
        .expect("parse fixture");
    render(&mapper, &ic, "55071").expect("the unmodified fixture renders");

    // The SummenzeitreihenDaten SG10 carrying CAV: keep the CAV's qualifier,
    // drop every field its CCI is built from. (SEQ+Z22 is "Daten der
    // Summenzeitreihe" — its own entity, not the SG5 Messlokation.)
    let tx = entities_mut(&mut ic.nachrichten[0].transaktionen[0]);
    let with_cav = tx["summenzeitreihenDaten"]
        .as_array_mut()
        .unwrap()
        .iter_mut()
        .find(|m| m["merkmal"].get("qualifier").is_some())
        .expect("a SummenzeitreihenDaten Merkmal with CAV qualifier");
    // The two fields the CCI of this group is built from: `code` carries
    // C240/7037 and `klasse` carries D_7059. Strip both and the CAV has no
    // entry segment left. (This group's guide leaves 7037 unrestricted in at
    // least one PID, so the code is stored raw rather than translated through
    // `Merkmaltyp` -- if that ever changes, the field to strip becomes `art`.)
    let merkmal = with_cav["merkmal"].as_object_mut().unwrap();
    merkmal.remove("code");
    merkmal.remove("klasse");

    let err = render(&mapper, &ic, "55071").expect_err("CAV without CCI must be refused");
    match &err {
        MapperError::MissingGroupEntrySegment(details) => {
            assert_eq!(details.pid, "55071");
            assert_eq!(details.group_path, "SG4.SG8.SG10");
            assert_eq!(details.entry_segment, "CCI");
            assert_eq!(details.present_segments, ["CAV".to_string()]);
            assert!(
                details
                    .entities
                    .contains(&"SummenzeitreihenDaten".to_string()),
                "{err}"
            );
            assert!(
                details
                    .entry_fields
                    .contains(&"SummenzeitreihenDaten.merkmal.code".to_string()),
                "{err}"
            );
        }
        other => panic!("expected MissingGroupEntrySegment, got {other:?}"),
    }
    let message = err.to_string();
    assert!(message.contains("'CCI'"), "{message}");
    assert!(
        message.contains("SummenzeitreihenDaten.merkmal.code"),
        "{message}"
    );

    // Validation renders it anyway and reports the defect instead of failing.
    let msg = &ic.nachrichten[0];
    mapper
        .validate_bo4e(
            &msg.stammdaten,
            &msg.transaktionen,
            "FV2604",
            "UTILMD_Strom",
            "55071",
            None,
            automapper_validation::ValidationLevel::Full,
        )
        .expect("validate_bo4e reports findings rather than failing");
}

/// The parse side of #103: a message whose SG10 lacks its CCI. The CAV cannot be
/// placed and its content is lost, but the loss is reported as an orphaned group
/// segment (not as a segment the MIG does not know), and the NADs after it still
/// map.
#[test]
fn cav_without_cci_is_reported_as_orphaned_on_parse_55042() {
    let Some((mapper, edifact)) = setup("55042") else {
        return;
    };
    assert!(edifact.contains("CCI+++E13'"));
    let broken = edifact.replace("CCI+++E13'", "");

    let (ic, diagnostics): (Dynamic, _) = mapper
        .from_edifact_with_diagnostics(&broken, "FV2604", "UTILMD_Strom", "55042")
        .expect("a CCI-less SG10 must not fail the whole conversion");

    let orphaned: Vec<_> = diagnostics
        .iter()
        .filter(|d| d.kind == mig_assembly::StructureDiagnosticKind::OrphanedGroupSegment)
        .collect();
    assert_eq!(orphaned.len(), 1, "{diagnostics:?}");
    assert_eq!(orphaned[0].segment_id, "CAV");
    assert!(
        orphaned[0].message.contains("SG10") && orphaned[0].message.contains("'CCI'"),
        "{}",
        orphaned[0].message
    );

    let tx = entities(&ic.nachrichten[0].transaktionen[0]);
    assert!(tx["zaehler"].get("geraeteNummer").is_none());
    assert_eq!(
        geschaeftspartner_count(tx),
        4,
        "NADs after the broken SG10 must survive"
    );
}