automapper-validation 0.41.2

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! What a message must keep consistent within itself, beyond what its AHB
//! conditions state: the APERAK errors Z34 and Z21.
//!
//! - **Z34, "Zeitintervall negativ oder Null":** a period whose end is not
//!   after its beginning. A period is a pair of DTM qualifiers that name its
//!   two ends ([`INTERVALS`]), sent side by side in one segment group.
//! - **Z21, "Geschäftsvorfallinterne Referenzierung fehlerhaft":** a UTILMD
//!   SG8 or SG12 `RFF` that refers to a location of its transaction
//!   ("Zur Referenzierung auf eine ID einer Messlokation vom SG5 LOC+Z17",
//!   UTILMD MIG) naming none ([`REFERENCES`], [`REFERENCES_GAS`]).
//!
//! Both are judged only where the message itself settles them. A period
//! whose ends are sent in different formats, and a reference in a
//! transaction without any location of the kind it refers to, are left
//! alone: no error is reported rather than a wrong one.

use mig_types::segment::OwnedSegment;

use crate::validator::{IssueKind, SegmentPosition, Severity, ValidationIssue};

/// DTM 2005 pairs naming a period's beginning and end, and whether the
/// period may be empty (end = beginning).
///
/// "Frühestes … spätestes" (469/472) bound a window that may close on the
/// day it opens; the others are periods, empty if they end as they begin.
pub const INTERVALS: &[(&str, &str, bool)] = &[
    ("92", "93", false),   // Vertragsbeginn / Vertragsende
    ("155", "156", false), // Rechnungsperiode Beginn / Ende
    ("158", "159", false), // Bilanzierungsbeginn / -ende
    ("163", "164", false), // Verarbeitung Beginn / Ende
    ("Z15", "Z16", false), // Bilanzierungsbeginn / -ende der beteiligten Marktrolle
    ("Z25", "Z26", false), // Verwendung der Daten ab / bis
    ("Z34", "Z35", false), // Gültigkeitsbeginn / -ende
    ("Z42", "Z43", false), // vorläufiger Abrechnungszeitraum Beginn / Ende
    ("469", "472", true),  // frühester / spätester Zeitpunkt
];

/// UTILMD Strom RFF 1153 qualifiers that refer to a location of the
/// transaction, and the LOC 3227 qualifiers they may name (UTILMD MIG Strom,
/// RFF descriptions: "Zur Referenzierung auf eine ID einer Messlokation vom
/// SG5 LOC+Z17").
pub const REFERENCES: &[(&str, &[&str])] = &[
    ("Z18", &["Z16", "Z21", "Z22"]), // Marktlokation, Tranche, ruhende Marktlokation
    ("Z19", &["Z17"]),               // Messlokation
    ("Z32", &["Z18"]),               // Netzlokation
    ("Z37", &["Z20"]),               // Technische Ressource
    ("Z38", &["Z19"]),               // Steuerbare Ressource
];

/// The same for UTILMD Gas, whose transactions name one location kind, the
/// Meldepunkt: "Zur Referenzierung auf eine ID einer Messlokation vom SG5
/// LOC+172" (UTILMD MIG Gas). Its Marktlokation reference (RFF+Z18, "die im
/// Vorgang behandelte Marktlokation") names no LOC, so it is not judged.
pub const REFERENCES_GAS: &[(&str, &[&str])] = &[("Z19", &["172"])];

/// The consistency errors of one message (`segments`, UNH…UNT) of
/// `message_type` (a variant, `UTILMD_Strom`, or a message type).
pub fn check_consistency(segments: &[OwnedSegment], message_type: &str) -> Vec<ValidationIssue> {
    let mut out = intervals(segments);
    match message_type {
        "UTILMD_Strom" => out.extend(references(segments, REFERENCES)),
        "UTILMD_Gas" => out.extend(references(segments, REFERENCES_GAS)),
        _ => {}
    }
    out.sort_by_key(|i| i.segment_position.map(|p| p.segment_number));
    out
}

/// A DTM value as minutes since 0000-03-01 UTC, for formats 102
/// (`CCYYMMDD`) and 303 (`CCYYMMDDHHMMZZZ`); `None` for anything else.
fn instant(value: &str, format: &str) -> Option<i64> {
    let digits = |s: &str| -> Option<i64> {
        if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
            return None;
        }
        s.parse().ok()
    };
    let (date, minutes, offset) = match format {
        "102" if value.len() == 8 => (value, 0, 0),
        "303" if value.len() == 15 => {
            let hours = digits(&value[8..10])?;
            let mins = digits(&value[10..12])?;
            let sign = match &value[12..13] {
                "+" => 1,
                "-" => -1,
                _ => return None,
            };
            (
                &value[..8],
                hours * 60 + mins,
                sign * digits(&value[13..15])? * 60,
            )
        }
        _ => return None,
    };
    let (y, m, d) = (
        digits(&date[..4])?,
        digits(&date[4..6])?,
        digits(&date[6..8])?,
    );
    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
        return None;
    }
    // Days since 0000-03-01 (H. Hinnant's days_from_civil, shifted).
    let y = if m <= 2 { y - 1 } else { y };
    let era = y.div_euclid(400);
    let yoe = y - era * 400;
    let mp = (m + 9) % 12;
    let doy = (153 * mp + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era * 146_097 + doe;
    Some(days * 24 * 60 + minutes - offset)
}

fn intervals(segments: &[OwnedSegment]) -> Vec<ValidationIssue> {
    let mut out = Vec::new();
    // DTMs sent side by side belong to one group; a period's two ends are
    // always among them.
    for run in segments.split(|s| s.id != "DTM") {
        for &(begin, end, may_be_empty) in INTERVALS {
            let find = |q: &str| run.iter().find(|s| s.get_component(0, 0) == q);
            let (Some(b), Some(e)) = (find(begin), find(end)) else {
                continue;
            };
            let format = b.get_component(0, 2);
            if format != e.get_component(0, 2) {
                continue;
            }
            let (Some(from), Some(to)) = (
                instant(b.get_component(0, 1), format),
                instant(e.get_component(0, 1), format),
            ) else {
                continue;
            };
            if to < from || (to == from && !may_be_empty) {
                out.push(
                    ValidationIssue::new(
                        Severity::Error,
                        IssueKind::IntervalNotPositive {
                            begin: format!("DTM+{begin}"),
                            end: format!("DTM+{end}"),
                        },
                    )
                    .with_position(SegmentPosition::at(e.segment_number))
                    .with_field_path("DTM/C507/2380")
                    .with_field_position(2, Some(2))
                    .with_actual(e.get_component(0, 1))
                    .with_expected(format!("> {}", b.get_component(0, 1))),
                );
            }
        }
    }
    out
}

fn references(segments: &[OwnedSegment], table: &[(&str, &[&str])]) -> Vec<ValidationIssue> {
    let mut out = Vec::new();
    // One transaction (SG4) after another: its LOCs (SG5), then the
    // references of its SG8/SG12 groups.
    for transaction in segments.split(|s| s.id == "IDE").skip(1) {
        let mut locs: Vec<(&str, &str)> = Vec::new();
        let mut in_sg8_or_sg12 = false;
        for seg in transaction {
            match seg.id.as_str() {
                "LOC" if !in_sg8_or_sg12 => {
                    locs.push((seg.get_element(0), seg.get_component(1, 0)));
                }
                "SEQ" | "NAD" => in_sg8_or_sg12 = true,
                "RFF" if in_sg8_or_sg12 => {
                    let qualifier = seg.get_component(0, 0);
                    let id = seg.get_component(0, 1);
                    let Some((_, kinds)) = table.iter().find(|(q, _)| *q == qualifier) else {
                        continue;
                    };
                    let named: Vec<&str> = locs
                        .iter()
                        .filter(|(k, v)| kinds.contains(k) && !v.is_empty())
                        .map(|(_, v)| *v)
                        .collect();
                    if id.is_empty() || named.is_empty() || named.contains(&id) {
                        continue;
                    }
                    out.push(
                        ValidationIssue::new(
                            Severity::Error,
                            IssueKind::ReferenceNotFound {
                                reference: format!("RFF+{qualifier}"),
                                value: id.to_string(),
                                targets: kinds.iter().map(|k| format!("LOC+{k}")).collect(),
                            },
                        )
                        .with_position(SegmentPosition::at(seg.segment_number))
                        .with_field_path("RFF/C506/1154")
                        .with_field_position(2, Some(2))
                        .with_actual(id)
                        .with_expected(named.join(", ")),
                    );
                }
                _ => {}
            }
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn message(text: &str) -> Vec<OwnedSegment> {
        mig_assembly::tokenize::parse_to_segments(text.as_bytes()).unwrap()
    }

    #[test]
    fn instants_compare_across_offsets() {
        let a = instant("202501010000+00", "303").unwrap();
        let b = instant("202501010100+01", "303").unwrap();
        assert_eq!(a, b);
        assert!(instant("20250301", "102").unwrap() > instant("20250228", "102").unwrap());
        assert_eq!(instant("2025013", "102"), None);
        assert_eq!(instant("202501010000+00", "610"), None);
    }

    #[test]
    fn a_period_ending_as_or_before_it_begins_is_z34() {
        let issues = |dtm: &str| {
            check_consistency(
                &message(&format!(
                    "UNH+1+MSCONS:D:04B:UN:2.5'QTY+220:1'{dtm}UNT+5+1'"
                )),
                "MSCONS",
            )
        };
        let backwards = issues("DTM+163:202501020000?+00:303'DTM+164:202501010000?+00:303'");
        assert_eq!(backwards.len(), 1, "{backwards:?}");
        assert_eq!(backwards[0].code(), "CON001");
        assert_eq!(
            backwards[0].segment_position.map(|p| p.segment_number),
            Some(4)
        );
        assert_eq!(
            issues("DTM+163:202501010000?+00:303'DTM+164:202501010000?+00:303'").len(),
            1
        );
        assert!(issues("DTM+163:202501010000?+00:303'DTM+164:202501020000?+00:303'").is_empty());
        // Different formats, other groups: not judged.
        assert!(issues("DTM+163:20250102:102'DTM+164:202501010000?+00:303'").is_empty());
        assert!(
            issues("DTM+163:202501020000?+00:303'QTY+220:1'DTM+164:202501010000?+00:303'")
                .is_empty()
        );
        // A window may close as it opens.
        assert!(issues("DTM+469:202501010000?+00:303'DTM+472:202501010000?+00:303'").is_empty());
    }

    #[test]
    fn a_reference_to_no_location_of_its_transaction_is_z21() {
        let issues = |rff: &str| {
            check_consistency(
                &message(&format!(
                    "UNH+1+UTILMD:D:11A:UN:S2.2'IDE+24+X'LOC+Z17+DE00MELO'LOC+Z18+E1814774964'\
                     SEQ+Z03'{rff}UNT+7+1'"
                )),
                "UTILMD_Strom",
            )
        };
        let wrong = issues("RFF+Z19:DE00ANDERE'");
        assert_eq!(wrong.len(), 1, "{wrong:?}");
        assert_eq!(wrong[0].code(), "CON002");
        assert!(issues("RFF+Z19:DE00MELO'").is_empty());
        // No Technische Ressource in the transaction: not judged.
        assert!(issues("RFF+Z37:C123'").is_empty());
        // Gas: a Messlokation is a Meldepunkt (LOC+172).
        let gas = |rff: &str| {
            check_consistency(
                &message(&format!(
                    "UNH+1+UTILMD:D:11A:UN:G1.2'IDE+24+X'LOC+172+DE00MELO'SEQ+Z03'{rff}UNT+6+1'"
                )),
                "UTILMD_Gas",
            )
        };
        assert_eq!(gas("RFF+Z19:DE00ANDERE'").len(), 1);
        assert!(gas("RFF+Z19:DE00MELO'").is_empty());
        // Another message type: not judged.
        assert!(check_consistency(
            &message("UNH+1+UTILTS:D:18A:UN:1.1e'IDE+24+X'LOC+Z17+A'SEQ+Z03'RFF+Z19:B'UNT+6+1'"),
            "UTILTS"
        )
        .is_empty());
    }
}