edifact-mapper 0.30.7

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! A UTILMD transaction that names a location in an SG8 `SEQ` or SG12 `NAD`
//! group (`RFF+Z32:<Netzlokations-ID>` under `SEQ+ZD7`, …) names one of its
//! own `LOC`s.
//!
//! The sample generator seeded every value per (entity, field), so the
//! reference and the `LOC` it points at came out as two unrelated ids, and
//! nothing checked it: no AHB condition compares them, and every roundtrip
//! carries the wrong id faithfully. A downstream reader that resolves the
//! reference refused the samples as inconsistent.

use std::collections::BTreeMap;
use std::path::PathBuf;

use mig_assembly::tokenize::parse_to_segments;

/// RFF 1153 qualifier naming a location → the LOC 3227 qualifiers it may
/// name, in preference order: a Marktlokation reference names the
/// transaction's Marktlokation, a ruhende one only when it has no other.
const REFERENCES: &[(&str, &[&str])] = &[
    ("Z18", &["Z16", "Z22"]), // Marktlokation
    ("Z19", &["Z17"]),        // Messlokation
    ("Z32", &["Z18"]),        // Netzlokation
    ("Z37", &["Z20"]),        // Technische Ressource
    ("Z38", &["Z19"]),        // Steuerbare Ressource
];

fn fixtures() -> Vec<PathBuf> {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/generated");
    let mut out = Vec::new();
    for fv in std::fs::read_dir(&root).expect("fixtures/generated") {
        let dir = fv.unwrap().path().join("utilmd");
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        out.extend(
            entries
                .map(|e| e.unwrap().path())
                .filter(|p| p.extension().is_some_and(|x| x == "edi")),
        );
    }
    out.sort();
    out
}

/// The violations in one sample: `(transaction, rff, id, locs of that type)`.
fn violations(edi: &[u8]) -> Vec<String> {
    let segments = parse_to_segments(edi).expect("sample tokenizes");
    let mut out = Vec::new();
    // One transaction: LOC ids by qualifier, and the references made in it.
    let mut locs: BTreeMap<String, Vec<String>> = BTreeMap::new();
    let mut refs: Vec<(String, String)> = Vec::new();
    let mut in_sg8_or_sg12 = false;
    let mut tx = 0;
    let check = |tx: usize,
                 locs: &BTreeMap<String, Vec<String>>,
                 refs: &[(String, String)],
                 out: &mut Vec<String>| {
        for (qualifier, id) in refs {
            let Some((_, kinds)) = REFERENCES.iter().find(|(q, _)| q == qualifier) else {
                continue;
            };
            let Some(ids) = kinds.iter().find_map(|k| locs.get(*k)) else {
                continue; // no LOC of that type to name
            };
            if !ids.contains(id) {
                out.push(format!(
                    "tx {tx}: RFF+{qualifier}:{id} names none of {ids:?}"
                ));
            }
        }
    };
    for seg in &segments {
        match seg.id.as_str() {
            "IDE" | "UNT" => {
                if tx > 0 {
                    check(tx, &locs, &refs, &mut out);
                }
                locs.clear();
                refs.clear();
                in_sg8_or_sg12 = false;
                tx += 1;
            }
            "LOC" if !in_sg8_or_sg12 => {
                let id = seg.get_component(1, 0);
                if !id.is_empty() {
                    locs.entry(seg.get_element(0).to_string())
                        .or_default()
                        .push(id.to_string());
                }
            }
            "SEQ" | "NAD" => in_sg8_or_sg12 = true,
            "RFF" if in_sg8_or_sg12 => {
                let id = seg.get_component(0, 1);
                if !id.is_empty() {
                    refs.push((seg.get_component(0, 0).to_string(), id.to_string()));
                }
            }
            _ => {}
        }
    }
    out
}

#[test]
fn every_location_reference_names_a_loc_of_its_transaction() {
    let mut failures = Vec::new();
    let samples = fixtures();
    assert!(
        samples.len() > 100,
        "found only {} UTILMD samples",
        samples.len()
    );
    for path in &samples {
        let edi = std::fs::read(path).unwrap();
        for v in violations(&edi) {
            failures.push(format!("{}: {v}", path.display()));
        }
    }
    assert!(
        failures.is_empty(),
        "{} location references name no LOC of their transaction:\n{}",
        failures.len(),
        failures.join("\n")
    );
}

#[test]
fn a_reference_to_another_location_is_caught() {
    let edi = b"UNA:+.? 'UNH+1+UTILMD:D:11A:UN:S2.1'IDE+24+X'LOC+Z18+E1814774964'\
SEQ+ZD7'RFF+Z32:E8032120031'UNT+5+1'";
    let v = violations(edi);
    assert_eq!(v.len(), 1, "{v:?}");
    let ok = b"UNA:+.? 'UNH+1+UTILMD:D:11A:UN:S2.1'IDE+24+X'LOC+Z18+E1814774964'\
SEQ+ZD7'RFF+Z32:E1814774964'UNT+5+1'";
    assert!(violations(ok).is_empty());
}