automapper-validation 0.50.1

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! The product codes of the Codeliste der Konfigurationen, per chapter: what
//! a PIA+5 Produkt-Code (C212 DE7140 beside DE7143 `Z11`/`Z12`) may carry where
//! an AHB condition names a chapter — "Es sind nur die Messprodukte erlaubt,
//! die in der Codeliste der Konfigurationen im Kapitel 4.6.1 "Werte nach Typ 2
//! aus Backend" enthalten sind" (QUOTES `[74]`), "Messprodukt-Code aus dem
//! Kapitel 2.3 ... der Codeliste der Konfigurationen" (ORDERS `[100]`).
//!
//! A chapter holds the codes of its subchapters too: chapter 2.1 is 2.1.1
//! and 2.1.2. Chapter 4.5 has none in either edition ("Derzeit sind keine
//! Messprodukte ... vorhanden").
//!
//! Data source: the Codeliste's .docx editions, extracted with
//! `scripts/extract_konfigurationen_produkte.py`. Which edition a format
//! version reads is [`Edition::of_format_version`].

use serde::Deserialize;
use std::collections::HashMap;
use std::sync::OnceLock;

pub use super::abrechnungsdaten::Edition;

const EMBEDDED_JSON: &str = include_str!("../../data/konfigurationen_produkte.json");

/// One product of a chapter.
#[derive(Debug, Clone, Deserialize)]
pub struct Product {
    /// The 13-digit code.
    pub code: String,
    pub name: String,
}

#[derive(Debug, Deserialize)]
struct Chapter {
    title: String,
    products: Vec<Product>,
}

#[derive(Debug, Deserialize)]
struct RawVersion {
    chapters: HashMap<String, Chapter>,
}

#[derive(Debug, Deserialize)]
struct RawFile {
    versions: HashMap<String, RawVersion>,
}

fn editions() -> &'static HashMap<String, RawVersion> {
    static CELL: OnceLock<HashMap<String, RawVersion>> = OnceLock::new();
    CELL.get_or_init(|| {
        serde_json::from_str::<RawFile>(EMBEDDED_JSON)
            .expect("embedded konfigurationen_produkte.json is malformed")
            .versions
    })
}

fn edition_key(edition: Edition) -> &'static str {
    match edition {
        Edition::V1_3c => "1.3c",
        Edition::V1_4 => "1.4",
    }
}

/// The products of `chapter` (`"4.6.1"`, `"2.3"`, `"7"`) and its subchapters
/// in `edition`, in the document's order. Empty for a chapter the edition
/// does not have or that lists none.
pub fn products(edition: Edition, chapter: &str) -> Vec<&'static Product> {
    let Some(version) = editions().get(edition_key(edition)) else {
        return Vec::new();
    };
    let prefix = format!("{chapter}.");
    let mut numbers: Vec<&String> = version
        .chapters
        .keys()
        .filter(|n| *n == chapter || n.starts_with(&prefix))
        .collect();
    numbers.sort_by_key(|n| {
        n.split('.')
            .map(|p| p.parse::<u32>().unwrap_or(0))
            .collect::<Vec<_>>()
    });
    let mut out: Vec<&'static Product> = Vec::new();
    for n in numbers {
        for p in &version.chapters[n].products {
            if !out.iter().any(|o| o.code == p.code) {
                out.push(p);
            }
        }
    }
    out
}

/// The title of `chapter` in `edition`.
pub fn title(edition: Edition, chapter: &str) -> Option<&'static str> {
    editions()
        .get(edition_key(edition))?
        .chapters
        .get(chapter)
        .map(|c| c.title.as_str())
}

/// The chapters of the Codeliste der Konfigurationen a condition text names
/// (`"... der Codeliste der Konfigurationen im Kapitel 4.6.1 ..."` →
/// `["4.6.1"]`). Empty for a text that does not name the Codeliste.
pub fn chapters_named(text: &str) -> Vec<String> {
    if !text.contains("Codeliste der Konfigurationen") {
        return Vec::new();
    }
    let mut out: Vec<String> = Vec::new();
    for (i, _) in text.match_indices("Kapitel") {
        let rest = text[i + "Kapitel".len()..].trim_start();
        let number: String = rest
            .chars()
            .take_while(|c| c.is_ascii_digit() || *c == '.')
            .collect();
        let number = number.trim_end_matches('.');
        if !number.is_empty() && !out.iter().any(|n| n == number) {
            out.push(number.to_string());
        }
    }
    out
}

/// The product codes a condition text allows in `fv`: those of every chapter
/// it names. Empty for a text naming none, or a format version without an
/// edition.
pub fn codes_named_by(fv: &str, text: &str) -> Vec<&'static str> {
    let Some(edition) = Edition::of_format_version(fv) else {
        return Vec::new();
    };
    let mut out: Vec<&'static str> = Vec::new();
    for chapter in chapters_named(text) {
        for p in products(edition, &chapter) {
            if !out.contains(&p.code.as_str()) {
                out.push(&p.code);
            }
        }
    }
    out
}

/// The chapter of `edition` a product `code` is listed in, the deepest one
/// (`"4.6.1"`); `None` for a code no chapter lists.
pub fn chapter_of(edition: Edition, code: &str) -> Option<&'static str> {
    let version = editions().get(edition_key(edition))?;
    version
        .chapters
        .iter()
        .filter(|(_, c)| c.products.iter().any(|p| p.code == code))
        .map(|(n, _)| n.as_str())
        .max_by_key(|n| n.matches('.').count())
}

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

    #[test]
    fn a_chapter_holds_its_subchapters() {
        let marktlokation = products(Edition::V1_4, "2.1");
        let mit = products(Edition::V1_4, "2.1.1");
        let ohne = products(Edition::V1_4, "2.1.2");
        assert_eq!(marktlokation.len(), mit.len() + ohne.len());
        assert!(!mit.is_empty() && !ohne.is_empty());
    }

    #[test]
    fn esa_backend_and_smgw_are_apart() {
        let backend = products(Edition::V1_4, "4.6.1");
        let smgw = products(Edition::V1_4, "4.6.2");
        assert!(backend.iter().any(|p| p.code == "9991000000416"));
        assert!(smgw.iter().any(|p| p.code == "9991000000432"));
        assert!(!backend
            .iter()
            .any(|p| smgw.iter().any(|s| s.code == p.code)));
        // The footnote mark in `9991 00000 153 95` is not part of the code.
        assert!(backend.iter().any(|p| p.code == "9991000001539"));
    }

    #[test]
    fn chapter_4_5_is_empty() {
        assert!(products(Edition::V1_3c, "4.5").is_empty());
        assert!(products(Edition::V1_4, "4.5").is_empty());
        assert!(title(Edition::V1_4, "4.5").is_some());
    }

    #[test]
    fn condition_texts_name_their_chapters() {
        let text = "Es sind nur die Messprodukte erlaubt, die in der Codeliste der \
                    Konfigurationen im Kapitel 4.6.1 \"Werte nach Typ 2 aus Backend\" enthalten sind.";
        assert_eq!(chapters_named(text), ["4.6.1"]);
        let gas = "Messprodukt-Code aus Kapitel 3 \"Codeliste der Standard-Messprodukte Gas\" \
                   der Codeliste der Konfigurationen";
        assert_eq!(chapters_named(gas), ["3"]);
        assert!(chapters_named("Kapitel 4.6.1 der Codeliste der Artikelnummern").is_empty());
        assert_eq!(codes_named_by("FV2610", gas).len(), 13);
        assert!(codes_named_by("FV2310", gas).is_empty());
    }

    #[test]
    fn a_code_knows_its_chapter() {
        assert_eq!(chapter_of(Edition::V1_4, "9991000000713"), Some("4.1"));
        assert_eq!(chapter_of(Edition::V1_4, "9991000000416"), Some("4.6.1"));
        assert_eq!(chapter_of(Edition::V1_4, "1234567890123"), None);
    }
}