edifact-mapper 0.2.0

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! APERAK through the public `Mapper` facade.
//!
//! The APERAK AHB carries two AWF rows and leaves the `Pruefidentifikator`
//! empty on **both**: "Fehlermeldung" and "Anerkennungs- meldung". Unlike
//! CONTRL's three rows these are two genuinely different message shapes — the
//! Fehlermeldung has the `SG4` error group (`ERC`/`FTX`) and its `SG5`
//! reference variants, the Anerkennungsmeldung has no `SG4` at all and instead
//! splits `SG2` into the `ACE`/`AGO`/`TN` reference variants. Market
//! communication addresses them as 92001 and 92002, and this repo generates
//! both.
//!
//! Before the APERAK pass only the first row reached the toolchain. The AHB
//! parser handed every consumer one blank id, so `compile-mappings` keyed both
//! rows under `pid_` (last row wins) and `cache/mappings/<FV>/APERAK.json`
//! carried a single `pid_` entry describing the Anerkennungsmeldung next to a
//! `pid_92001` that only existed because a `pid_92001/` mapping directory did.
//! 92002 was not a PID anywhere in the bundle: `from_edifact(.., "92002")`
//! could not resolve an engine, and the generated Anerkennungsmeldung fixture
//! converted to almost nothing.
//!
//! What this test pins down:
//!
//!  1. `detect_pid` tells the two shapes apart from the message itself —
//!     `BGM` DE1001 is `313` for the Fehlermeldung and `312` for the
//!     Anerkennungsmeldung, and each PID's schema allows exactly one of them.
//!  2. `from_edifact` yields real entities for **both** PIDs — the
//!     `Marktteilnehmer` and `Referenz` every APERAK has, plus one transaction
//!     per `SG4` error for 92001.
//!  3. `to_edifact` rebuilds the message body byte-identically from that BO4E
//!     alone, for both shapes and every format version.
//!  4. The bundle carries exactly 92001 and 92002 — no leftover blank `pid_`.

use std::path::PathBuf;

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

const FORMAT_VERSIONS: [&str; 4] = ["FV2504", "FV2510", "FV2604", "FV2610"];

/// The Fehlermeldung — `BGM+313`, `SG4` error groups.
const PID_FEHLERMELDUNG: &str = "92001";
/// The Anerkennungsmeldung — `BGM+312`, no `SG4`.
const PID_ANERKENNUNG: &str = "92002";

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

fn dist_dir() -> PathBuf {
    repo_root().join("dist")
}

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

fn tag_of(segment: &str) -> &str {
    segment.split(['+', ':']).next().unwrap_or(segment)
}

/// What `Mapper::to_edifact` must produce: the message body between UNH and
/// UNT, segment-terminated, without newlines.
fn expected_body(edifact: &str) -> String {
    segments(edifact)
        .into_iter()
        .filter(|s| !["UNA", "UNB", "UNH", "UNT", "UNZ"].contains(&tag_of(s)))
        .map(|s| format!("{s}'"))
        .collect()
}

/// `fixtures/generated/<fv>/aperak/<pid>.edi`.
fn generated_fixture(fv: &str, pid: &str) -> PathBuf {
    repo_root()
        .join("fixtures/generated")
        .join(fv.to_lowercase())
        .join("aperak")
        .join(format!("{pid}.edi"))
}

// ── 1. PID detection ──────────────────────────────────────────────────────

/// Both shapes, from the generated fixtures and from the real corpus.
///
/// The corpus filenames all say `92001` — that convention predates the two
/// PIDs and is wrong for `JOSCHA60014103`, whose `BGM+312` and missing `ERC`
/// make it an Anerkennungsmeldung. The message decides, not the filename.
fn detection_cases() -> Vec<(String, String, &'static str)> {
    let mut out = Vec::new();
    for fv in FORMAT_VERSIONS {
        for pid in [PID_FEHLERMELDUNG, PID_ANERKENNUNG] {
            let path = generated_fixture(fv, pid);
            let text = std::fs::read_to_string(&path)
                .unwrap_or_else(|e| panic!("{}: {e}", path.display()));
            out.push((format!("{fv}/{pid}.edi"), text, pid));
        }
    }
    let corpus = repo_root().join("example_market_communication_bo4e_transactions/APERAK/FV2504");
    for (file, expected) in [
        ("92001_APERAK_2.1i_DEV-99155.edi", PID_FEHLERMELDUNG),
        ("92001_APERAK_2.1i_DEV-99155-2.edi", PID_FEHLERMELDUNG),
        ("92001_APERAK_2.1f_JOSCHA60014103.edi", PID_ANERKENNUNG),
    ] {
        if let Ok(text) = std::fs::read_to_string(corpus.join(file)) {
            out.push((file.to_string(), text, expected));
        }
    }
    out
}

#[test]
fn detect_pid_tells_the_two_aperak_shapes_apart() {
    let dist = dist_dir();
    if !dist.join("edifact-data-FV2504.bin").exists() {
        eprintln!("skipping: dist/ bundles missing");
        return;
    }
    let mapper = Mapper::from_data_dir(DataDir::path(&dist)).expect("load bundles");

    let cases = detection_cases();
    assert!(
        cases.len() >= 11,
        "expected 8 generated fixtures plus the FV2504 corpus, got {}",
        cases.len()
    );
    let mut failures = Vec::new();
    for (label, edifact, expected) in cases {
        match mapper.detect_pid(&edifact) {
            Ok(pid) if pid == expected => {}
            Ok(pid) => failures.push(format!("{label}: detected {pid}, expected {expected}")),
            Err(e) => failures.push(format!("{label}: detect_pid failed: {e}")),
        }
    }
    assert!(failures.is_empty(), "{}", failures.join("\n"));
}

/// An APERAK whose `BGM` carries neither 312 nor 313 names no PID. Guessing one
/// would hand the caller the wrong message shape.
#[test]
fn detect_pid_refuses_an_unknown_aperak_document_code() {
    let dist = dist_dir();
    if !dist.join("edifact-data-FV2504.bin").exists() {
        eprintln!("skipping: dist/ bundles missing");
        return;
    }
    let mapper = Mapper::from_data_dir(DataDir::path(&dist)).expect("load bundles");
    let edifact = "UNB+UNOC:3+A:500+B:500+250401:1200+REF'\
UNH+MSG+APERAK:D:07B:UN:2.1i'\
BGM+999+MSGBGM'\
UNT+3+MSG'UNZ+1+REF'";
    assert!(
        mapper.detect_pid(edifact).is_err(),
        "an APERAK with BGM+999 must fail loudly, not resolve to 92001 or 92002"
    );
}

// ── 2./3. forward + reverse through the bundle ────────────────────────────

struct Case {
    fv: &'static str,
    pid: &'static str,
    /// One transaction per `SG4` error group; the Anerkennungsmeldung has none.
    errors: usize,
}

const CASES: &[Case] = &[
    Case {
        fv: "FV2504",
        pid: PID_FEHLERMELDUNG,
        errors: 1,
    },
    Case {
        fv: "FV2504",
        pid: PID_ANERKENNUNG,
        errors: 0,
    },
    Case {
        fv: "FV2510",
        pid: PID_FEHLERMELDUNG,
        errors: 1,
    },
    Case {
        fv: "FV2510",
        pid: PID_ANERKENNUNG,
        errors: 0,
    },
    Case {
        fv: "FV2604",
        pid: PID_FEHLERMELDUNG,
        errors: 1,
    },
    Case {
        fv: "FV2604",
        pid: PID_ANERKENNUNG,
        errors: 0,
    },
    Case {
        fv: "FV2610",
        pid: PID_FEHLERMELDUNG,
        errors: 1,
    },
    Case {
        fv: "FV2610",
        pid: PID_ANERKENNUNG,
        errors: 0,
    },
];

/// Every entity the message-level mapping must produce for any APERAK.
///
/// `msg` is the message stammdaten with the hoisted `nachricht` metadata put
/// back — the same shape `to_edifact` is fed.
fn check_message_entities(label: &str, msg: &Value, failures: &mut Vec<String>) {
    // `Nachricht` is the root BGM/DTM. `from_edifact` hoists it out of the
    // stammdaten into `nachrichtendaten`; `restore_message_metadata` puts it
    // back.
    match msg.pointer("/nachricht/nachrichtennummer") {
        Some(Value::String(s)) if !s.is_empty() => {}
        _ => failures.push(format!(
            "{label}: `nachricht.nachrichtennummer` missing; message = {msg}"
        )),
    }
    match msg.pointer("/nachricht/dokumentenCode") {
        Some(v) if !v.is_null() => {}
        _ => failures.push(format!(
            "{label}: `nachricht.dokumentenCode` missing; message = {msg}"
        )),
    }
    // `Marktteilnehmer` comes from SG3's NAD+MS / NAD+MR.
    if msg.get("marktteilnehmer").is_none() {
        failures.push(format!("{label}: no `marktteilnehmer`; message = {msg}"));
    }
    // `Referenz` comes from SG2's RFF. Both shapes have at least RFF+ACE.
    match msg.get("referenz") {
        Some(r) if !r.is_null() => {}
        _ => failures.push(format!("{label}: no `referenz`; message = {msg}")),
    }
}

#[test]
fn aperak_maps_to_entities_and_back_for_both_pids() {
    let dist = dist_dir();
    if !dist.join("edifact-data-FV2504.bin").exists() {
        eprintln!("skipping: dist/ bundles missing");
        return;
    }

    let mut failures: Vec<String> = Vec::new();
    for case in CASES {
        let label = format!("{}/{}", case.fv, case.pid);
        let path = generated_fixture(case.fv, case.pid);
        let Ok(edifact) = std::fs::read_to_string(&path) else {
            failures.push(format!("{label}: fixture missing at {}", path.display()));
            continue;
        };
        let mapper =
            Mapper::from_data_dir(DataDir::path(&dist).eager(&[case.fv])).expect("load bundle");

        let ic = match mapper.from_edifact::<Value, Value>(&edifact, case.fv, "APERAK", case.pid) {
            Ok(ic) => ic,
            Err(e) => {
                failures.push(format!("{label}: from_edifact failed: {e}"));
                continue;
            }
        };
        let nachricht = &ic.nachrichten[0];
        let mut msg = nachricht.stammdaten.clone();
        mig_bo4e::model::restore_message_metadata(&mut msg, &nachricht.nachrichtendaten);
        check_message_entities(&label, &msg, &mut failures);

        if nachricht.transaktionen.len() != case.errors {
            failures.push(format!(
                "{label}: expected {} error transaction(s), got {}: {:?}",
                case.errors,
                nachricht.transaktionen.len(),
                nachricht.transaktionen
            ));
        } else {
            for (i, tx) in nachricht.transaktionen.iter().enumerate() {
                let stamm = tx.get("stammdaten").unwrap_or(tx);
                let Some(fehler) = stamm.get("fehler") else {
                    failures.push(format!("{label}: transaction {i} has no `fehler`: {stamm}"));
                    continue;
                };
                if fehler.get("fehlerCode").is_none() {
                    failures.push(format!(
                        "{label}: transaction {i} `fehler.fehlerCode` missing: {fehler}"
                    ));
                }
            }
        }

        // Reverse from the BO4E alone.
        match mapper.to_edifact(&msg, &nachricht.transaktionen, case.fv, "APERAK", case.pid) {
            Ok(rendered) => {
                let want = expected_body(&edifact);
                if rendered != want {
                    failures.push(format!(
                        "{label}: reverse is not byte-identical\n  want: {want}\n  got:  {rendered}"
                    ));
                }
            }
            Err(e) => failures.push(format!("{label}: to_edifact failed: {e}")),
        }
    }

    assert!(
        failures.is_empty(),
        "APERAK is not fully reachable through `Mapper`:\n{}",
        failures.join("\n")
    );
}

/// The real FV2504 corpus, through the facade rather than the engine-level
/// harness: three files, two of them Fehlermeldungen and one — despite its
/// filename — an Anerkennungsmeldung.
#[test]
fn aperak_corpus_roundtrips_through_the_mapper() {
    let dist = dist_dir();
    if !dist.join("edifact-data-FV2504.bin").exists() {
        eprintln!("skipping: dist/ bundles missing");
        return;
    }
    let corpus = repo_root().join("example_market_communication_bo4e_transactions/APERAK/FV2504");
    if !corpus.is_dir() {
        eprintln!("skipping: APERAK corpus not checked out");
        return;
    }
    let mapper = Mapper::from_data_dir(DataDir::path(&dist).eager(&["FV2504"])).expect("bundle");

    let mut files: Vec<PathBuf> = std::fs::read_dir(&corpus)
        .unwrap()
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().is_some_and(|e| e == "edi"))
        .collect();
    files.sort();
    assert!(!files.is_empty(), "APERAK FV2504 corpus is empty");

    let mut failures = Vec::new();
    for file in files {
        let label = file.file_name().unwrap().to_string_lossy().into_owned();
        let edifact = std::fs::read_to_string(&file).unwrap();
        let pid = match mapper.detect_pid(&edifact) {
            Ok(p) => p,
            Err(e) => {
                failures.push(format!("{label}: detect_pid failed: {e}"));
                continue;
            }
        };
        let ic = match mapper.from_edifact::<Value, Value>(&edifact, "FV2504", "APERAK", &pid) {
            Ok(ic) => ic,
            Err(e) => {
                failures.push(format!("{label} ({pid}): from_edifact failed: {e}"));
                continue;
            }
        };
        let nachricht = &ic.nachrichten[0];
        let mut msg = nachricht.stammdaten.clone();
        mig_bo4e::model::restore_message_metadata(&mut msg, &nachricht.nachrichtendaten);
        check_message_entities(&format!("{label} ({pid})"), &msg, &mut failures);

        match mapper.to_edifact(&msg, &nachricht.transaktionen, "FV2504", "APERAK", &pid) {
            Ok(rendered) => {
                let want = expected_body(&edifact);
                if rendered != want {
                    failures.push(format!(
                        "{label} ({pid}): reverse is not byte-identical\n  want: {want}\n  got:  {rendered}"
                    ));
                }
            }
            Err(e) => failures.push(format!("{label} ({pid}): to_edifact failed: {e}")),
        }
    }
    assert!(failures.is_empty(), "{}", failures.join("\n"));
}

/// The bundle must carry APERAK's two PIDs and nothing else — a leftover blank
/// `pid_` would mean the AHB parser still hands both AWF rows the same empty
/// id, and a missing `pid_92002` that the Anerkennungsmeldung is unreachable.
#[test]
fn aperak_bundles_carry_exactly_two_pids() {
    let dist = dist_dir();
    let expected: Vec<String> = vec![
        format!("pid_{PID_FEHLERMELDUNG}"),
        format!("pid_{PID_ANERKENNUNG}"),
    ];
    let mut failures = Vec::new();
    for fv in FORMAT_VERSIONS {
        let path = dist.join(format!("edifact-data-{fv}.bin"));
        if !path.exists() {
            eprintln!("skipping {fv}: bundle missing");
            continue;
        }
        let bundle = mig_bo4e::engine::DataBundle::load(&path).expect("load bundle");
        let Some(vc) = bundle.variant("APERAK") else {
            failures.push(format!("{fv}: no APERAK variant in bundle"));
            continue;
        };
        if vc.message_defs.is_empty() {
            failures.push(format!("{fv}: APERAK message_defs is empty"));
        }
        let mut pids: Vec<String> = vc.transaction_defs.keys().cloned().collect();
        pids.sort();
        let mut want = expected.clone();
        want.sort();
        if pids != want {
            failures.push(format!("{fv}: APERAK PIDs are {pids:?}, expected {want:?}"));
        }
        // The Fehlermeldung's SG4 error group is transaction level.
        match vc.transaction_defs.get(&format!("pid_{PID_FEHLERMELDUNG}")) {
            Some(defs) if !defs.is_empty() => {}
            other => failures.push(format!(
                "{fv}: APERAK {PID_FEHLERMELDUNG} must map SG4, got {:?}",
                other.map(Vec::len)
            )),
        }
        // Both PIDs need their AHB segment numbers, or the PID-filtered MIG
        // cannot be built and `from_edifact` fails with "No MIG schema".
        for pid in [PID_FEHLERMELDUNG, PID_ANERKENNUNG] {
            let key = format!("pid_{pid}");
            match vc.pid_segment_numbers.get(&key) {
                Some(numbers) if !numbers.is_empty() => {}
                other => failures.push(format!(
                    "{fv}: APERAK {key} carries no AHB segment numbers: {other:?}"
                )),
            }
            if !vc.pid_requirements.contains_key(&key) {
                failures.push(format!("{fv}: APERAK {key} has no PID requirements"));
            }
        }
    }
    assert!(failures.is_empty(), "{}", failures.join("\n"));
}