edifact-mapper 0.30.6

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! A code element whose status the AHB gives per code is judged by the code
//! sent, on the BO4E path as on the EDIFACT path.
//!
//! Reported by mako-twin against 0.30.0: UTILMD 55010's STS+7 Transaktionsgrund
//! lists E01 `X [192]` ("Wenn … ZW4/ZAP vorhanden") and E03 `X`. The PID
//! schema gives the element E01's status, so `validate_pid_with_conditions`
//! judged every Transaktionsgrund by `[192]` and refused an E03 beside ZW3.
//! The EDIFACT validator reads the code's own status (`governing_status`).

use std::path::PathBuf;

use edifact_mapper::{DataDir, Mapper};
use serde_json::Value;

fn root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}

fn mapper(fv: &str) -> Option<Mapper> {
    if !root().join(format!("dist/edifact-data-{fv}.bin")).exists() {
        eprintln!("skipping {fv}: build the bundle first");
        return None;
    }
    Some(Mapper::from_data_dir(DataDir::path(root().join("dist")).eager(&[fv])).expect("bundle"))
}

/// The BO4E path's errors for the one transaction of `edi`.
fn bo4e_errors(m: &Mapper, fv: &str, pid: &str, edi: &str) -> Vec<String> {
    let ic = m
        .from_edifact::<Value, Value>(edi, fv, "UTILMD_Strom", pid)
        .expect("parse");
    let n = &ic.nachrichten[0];
    let mut entities = n.stammdaten.clone();
    let map = entities.as_object_mut().expect("stammdaten");
    map.insert(
        "nachricht".into(),
        Value::Object(n.nachrichtendaten.nachricht.clone()),
    );
    let tx = &n.transaktionen[0];
    for (k, v) in tx["stammdaten"].as_object().into_iter().flatten() {
        map.insert(k.clone(), v.clone());
    }
    map.insert("prozessdaten".into(), tx["transaktionsdaten"].clone());
    m.validate_pid_with_conditions(&entities, fv, "UTILMD_Strom", pid)
        .expect("validate")
        .iter()
        .map(|e| e.to_string())
        .filter(|e| e.starts_with("ERROR") || e.starts_with("INVALID"))
        .collect()
}

#[test]
fn the_transaktionsgrund_is_judged_by_its_own_code() {
    for fv in ["FV2510", "FV2604", "FV2610"] {
        let Some(m) = mapper(fv) else {
            continue;
        };
        let sample = root().join(format!(
            "fixtures/generated/{}/utilmd/55010__ZW3.edi",
            fv.to_lowercase()
        ));
        let edi = std::fs::read_to_string(&sample).expect("sample");
        assert!(edi.contains("STS+7++E03+ZW3'"), "{fv}: {edi}");

        // E03 (`X`) beside ZW3: allowed.
        let found = bo4e_errors(&m, fv, "55010", &edi);
        assert!(found.is_empty(), "{fv} E03: {found:#?}");

        // E01 (`X [192]`, "Wenn … ZW4/ZAP vorhanden") beside ZW3: refused.
        let e01 = edi.replace("STS+7++E03+ZW3'", "STS+7++E01+ZW3'");
        let found = bo4e_errors(&m, fv, "55010", &e01);
        assert!(
            found
                .iter()
                .any(|e| e.contains("transaktionsgrund") && e.contains("[192]")),
            "{fv} E01: {found:#?}"
        );
    }
}

/// The EDIFACT validator's findings on the Gerätereferenz's qualifier.
fn edifact_errors(m: &Mapper, fv: &str, pid: &str, edi: &str) -> Vec<String> {
    use automapper_validation::{Severity, ValidationLevel};
    m.validate_edifact_for_pid(edi, fv, "UTILMD_Strom", pid, ValidationLevel::Full)
        .expect("validate")
        .issues
        .iter()
        .filter(|i| i.severity != Severity::Info)
        .filter(|i| {
            i.field_path
                .as_deref()
                .is_some_and(|p| p.ends_with("RFF/C506/1153"))
        })
        .map(|i| format!("{:?} {:?} {:?}", i.kind, i.field_path, i.rule))
        .collect()
}

/// Reported by mako-twin against 0.30.2: UTILMD 55643 / 55653 (Änderung Daten
/// der MeLo) SG8 SEQ+Z20 RFF "Referenz auf die Gerätenummer" lists MG `X
/// [204]` ("Wenn SG8 SEQ+Z03 RFF+Z14 nicht vorhanden") and Z14 `X`. The
/// segment's own `Muss [665]` joined into each code's condition, so the first
/// code's no longer read as the element's and every qualifier was judged by
/// `[204]`: the Smartmeter-Gateway Z14 of an iMS point was refused.
#[test]
fn the_geraetereferenz_is_judged_by_its_own_code() {
    let gateway = "SEQ+Z03+1'RFF+Z14:GATEWAY00001'";
    for fv in ["FV2504", "FV2510", "FV2604", "FV2610"] {
        let Some(m) = mapper(fv) else {
            continue;
        };
        for (pid, field) in [
            ("55643", "rffQualifierGeraet"),
            ("55653", "geraetereferenzQualifier"),
        ] {
            let sample = root().join(format!(
                "fixtures/generated/{}/utilmd/{pid}.edi",
                fv.to_lowercase()
            ));
            let edi = std::fs::read_to_string(&sample).expect("sample");
            assert!(edi.contains("SEQ+Z20+1'RFF+MG:"), "{fv} {pid}: {edi}");
            // The meter behind a gateway (55653's sample has one already).
            let behind = if edi.contains("SEQ+Z03+1'RFF+Z14:") {
                edi.clone()
            } else {
                edi.replace("SEQ+Z03+1'", gateway)
            };
            let z14 = behind.replace("SEQ+Z20+1'RFF+MG:", "SEQ+Z20+1'RFF+Z14:");
            let ctx = format!("{fv} {pid}");
            let of_field = |found: Vec<String>| -> Vec<String> {
                found.into_iter().filter(|e| e.contains(field)).collect()
            };

            // Z14 (`X`), the meter behind a gateway: allowed on both paths.
            let found = of_field(bo4e_errors(&m, fv, pid, &z14));
            assert!(found.is_empty(), "{ctx} Z14 behind a gateway: {found:#?}");
            let found = edifact_errors(&m, fv, pid, &z14);
            assert!(found.is_empty(), "{ctx} Z14 (EDIFACT): {found:#?}");

            // MG (`X [204]`), the meter behind a gateway: refused on both.
            let found = of_field(bo4e_errors(&m, fv, pid, &behind));
            assert!(
                found.iter().any(|e| e.contains("[204]")),
                "{ctx} MG behind a gateway: {found:#?}"
            );
            assert!(
                !edifact_errors(&m, fv, pid, &behind).is_empty(),
                "{ctx} MG behind a gateway (EDIFACT)"
            );

            // MG, a meter without a gateway: allowed on both.
            let plain = behind.replace(gateway, "SEQ+Z03+1'");
            let plain = match plain.find("SEQ+Z03+1'RFF+Z14:") {
                Some(at) => {
                    let rff = at + "SEQ+Z03+1'".len();
                    let end = rff + plain[rff..].find('\'').expect("RFF") + 1;
                    format!("{}{}", &plain[..rff], &plain[end..])
                }
                None => plain,
            };
            assert!(!plain.contains("SEQ+Z03+1'RFF+Z14:"), "{ctx}: {plain}");
            let found = of_field(bo4e_errors(&m, fv, pid, &plain));
            assert!(found.is_empty(), "{ctx} MG without a gateway: {found:#?}");
            let found = edifact_errors(&m, fv, pid, &plain);
            assert!(found.is_empty(), "{ctx} MG (EDIFACT): {found:#?}");
        }
    }
}