automapper-validation 0.41.4

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! The products of chapter 6.2 of the Codeliste der Konfigurationen,
//! "Produkte zur Bestellung einer Änderung von Abrechnungsdaten": what an
//! ORDERS 17133 may order in PIA+5 DE7140, and what each product takes in its
//! SG30 — a Produkteigenschaft (CAV+ZH9 DE7110) from its own list, or a
//! Wertedetail (CAV+ZV4 DE7110) of its own format.
//!
//! The AHB conditions reading it: `[158]` (the product is one of them),
//! `[159]` (it has Produkteigenschaften), `[160]` (the CAV+ZH9 code is one of
//! its own), `[162]` (the CAV+ZV4 value fits its format) and `[180]` (it has a
//! Wertedetail). Bilanzkreis takes an EIC (`[970]` an..17),
//! Jahresverbrauchsprognose a whole number (`[909]` ∧ `[937]`).
//!
//! Data source: the Codeliste's .docx editions, extracted with
//! `scripts/extract_konfigurationen_abrechnungsdaten.py`. FV2504 to FV2604
//! read version 1.3c, FV2610 version 1.4 (which adds spezifische Arbeit).

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

use super::{ConditionResult, EvaluationContext};
use crate::eval::format_validators::{is_edifact_number, validate_max_decimal_places};

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

/// The edition of the Codeliste der Konfigurationen a format version reads.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Edition {
    /// Version 1.3c: FV2504, FV2510, FV2604.
    V1_3c,
    /// Version 1.4: FV2610.
    V1_4,
}

impl Edition {
    /// The edition `fv` reads; `None` for a format version before FV2504.
    pub fn of_format_version(fv: &str) -> Option<Self> {
        match fv {
            "FV2504" | "FV2510" | "FV2604" => Some(Self::V1_3c),
            "FV2610" => Some(Self::V1_4),
            _ => None,
        }
    }

    fn key(self) -> &'static str {
        match self {
            Self::V1_3c => "1.3c",
            Self::V1_4 => "1.4",
        }
    }
}

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

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

/// One product of chapter 6.2.
#[derive(Debug, Deserialize)]
pub struct Product {
    /// The product code, PIA+5 DE7140.
    pub code: String,
    pub name: String,
    /// The codes its CAV+ZH9 DE7110 may carry; empty when it has none.
    pub property_codes: Vec<PropertyCode>,
    /// What its CAV+ZV4 DE7110 carries; `None` when it has none.
    pub wertedetails: Option<Wertedetails>,
}

#[derive(Debug, Deserialize)]
pub struct PropertyCode {
    pub code: String,
    pub name: String,
}

/// A product's Wertedetail: its name and the format conditions its value
/// must hold (`[970]`).
#[derive(Debug, Deserialize)]
pub struct Wertedetails {
    pub name: String,
    pub conditions: Vec<u32>,
}

impl Wertedetails {
    /// Whether `value` holds every format condition. Unknown for a condition
    /// the table names but this code does not know.
    pub fn fits(&self, value: &str) -> ConditionResult {
        let mut result = ConditionResult::True;
        for condition in &self.conditions {
            let holds = match condition {
                // Format: an..17
                970 => ConditionResult::from(value.chars().count() <= 17),
                // Format: Mögliche Werte: 0 bis n
                909 => ConditionResult::from(
                    is_edifact_number(value) && !value.trim_start().starts_with('-'),
                ),
                // Format: keine Nachkommastelle
                937 => {
                    ConditionResult::from(is_edifact_number(value) && !value.contains(['.', ',']))
                }
                // Format: max. 3 Nachkommastellen
                906 => validate_max_decimal_places(value, 3),
                _ => ConditionResult::Unknown,
            };
            match holds {
                ConditionResult::False => return ConditionResult::False,
                ConditionResult::Unknown => result = ConditionResult::Unknown,
                ConditionResult::True => {}
            }
        }
        result
    }
}

/// Chapter 6.2 in every edition the format versions read.
#[derive(Debug)]
pub struct AbrechnungsdatenProdukte {
    editions: HashMap<String, Vec<Product>>,
}

impl AbrechnungsdatenProdukte {
    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
        let raw: RawFile = serde_json::from_str(json)?;
        Ok(Self {
            editions: raw
                .versions
                .into_iter()
                .map(|(version, v)| (version, v.products))
                .collect(),
        })
    }

    pub fn embedded() -> &'static Self {
        static CELL: OnceLock<AbrechnungsdatenProdukte> = OnceLock::new();
        CELL.get_or_init(|| {
            AbrechnungsdatenProdukte::from_json(EMBEDDED_JSON)
                .expect("embedded konfigurationen_abrechnungsdaten.json is malformed")
        })
    }

    /// The products of `edition`, in the table's order.
    pub fn products(&self, edition: Edition) -> &[Product] {
        self.editions
            .get(edition.key())
            .map_or(&[], |products| products.as_slice())
    }

    /// The product `code` of `edition`, if the table lists it.
    pub fn product(&self, edition: Edition, code: &str) -> Option<&Product> {
        self.products(edition).iter().find(|p| p.code == code)
    }
}

/// The product code ordered in PIA+5 DE7140 of the SG29 the field sits in,
/// or of the message's first PIA+5 without a scope (17133 sends one SG29,
/// `[2050]`). `None` when there is none.
fn ordered_product(ctx: &EvaluationContext) -> Option<String> {
    ctx.scoped_find_segments_in("SG29", "PIA")
        .into_iter()
        .find(|s| s.get_element(0) == "5")
        .and_then(|s| s.elements.get(1)?.first().cloned())
        .filter(|code| !code.is_empty())
}

/// What `answer` makes of the ordered product: Unknown when there is none,
/// False for a product the table does not list.
fn of_product(
    ctx: &EvaluationContext,
    edition: Edition,
    answer: impl Fn(&Product) -> ConditionResult,
) -> ConditionResult {
    let Some(code) = ordered_product(ctx) else {
        return ConditionResult::Unknown;
    };
    match AbrechnungsdatenProdukte::embedded().product(edition, &code) {
        Some(product) => answer(product),
        None => ConditionResult::False,
    }
}

/// `[158]` Es sind nur die Produkt-Codes erlaubt, die in Kapitel 6.2 genannt
/// sind: judged on the element's own value (PIA+5 DE7140), else on the
/// ordered product.
pub fn product_is_listed(ctx: &EvaluationContext, edition: Edition) -> ConditionResult {
    let code = match ctx.resolved_value {
        Some(value) => value.to_string(),
        None => match ordered_product(ctx) {
            Some(code) => code,
            None => return ConditionResult::Unknown,
        },
    };
    ConditionResult::from(
        AbrechnungsdatenProdukte::embedded()
            .product(edition, &code)
            .is_some(),
    )
}

/// `[159]` Wenn der Produkt-Code in der Spalte "Code der Produkteigenschaft"
/// mit einem Code befüllt ist.
pub fn has_property_codes(ctx: &EvaluationContext, edition: Edition) -> ConditionResult {
    of_product(ctx, edition, |p| {
        ConditionResult::from(!p.property_codes.is_empty())
    })
}

/// `[160]` Nur die Codes der Produkteigenschaft des Produkts: the value of
/// CAV+ZH9 DE7110 is one of its codes.
pub fn property_code_allowed(ctx: &EvaluationContext, edition: Edition) -> ConditionResult {
    let Some(value) = ctx.resolved_value else {
        return ConditionResult::Unknown;
    };
    of_product(ctx, edition, |p| {
        ConditionResult::from(p.property_codes.iter().any(|c| c.code == value))
    })
}

/// `[162]` Nur der Wertebereich der Spalte "Wertedetails für Position": the
/// value of CAV+ZV4 DE7110 holds the product's format conditions. False for a
/// product without a Wertedetail.
pub fn wertedetails_value_fits(ctx: &EvaluationContext, edition: Edition) -> ConditionResult {
    let Some(value) = ctx.resolved_value else {
        return ConditionResult::Unknown;
    };
    of_product(ctx, edition, |p| match &p.wertedetails {
        Some(w) => w.fits(value),
        None => ConditionResult::False,
    })
}

/// `[180]` Wenn der Produkt-Code in der Spalte "Wertedetails für Position"
/// eine Angabe hat, deren ggf. enthaltene Bedingung erfüllt ist. The
/// Bedingungen there are the value's formats, which `[162]` beside it judges;
/// here, that the product has a Wertedetail.
pub fn has_wertedetails(ctx: &EvaluationContext, edition: Edition) -> ConditionResult {
    of_product(ctx, edition, |p| {
        ConditionResult::from(p.wertedetails.is_some())
    })
}

/// Values that satisfy the ORDERS `condition` of `fv` for some product of
/// chapter 6.2 — every product code for `[158]`, every Produkteigenschaft for
/// `[160]` — for a sample generator to offer an element the condition judges.
/// Empty for any other condition or message type.
pub fn candidate_values(message_type: &str, fv: &str, condition: u32) -> Vec<String> {
    let Some(edition) = Edition::of_format_version(fv).filter(|_| message_type == "ORDERS") else {
        return Vec::new();
    };
    let products = AbrechnungsdatenProdukte::embedded().products(edition);
    match condition {
        158 => products.iter().map(|p| p.code.clone()).collect(),
        160 => products
            .iter()
            .flat_map(|p| p.property_codes.iter().map(|c| c.code.clone()))
            .collect(),
        _ => Vec::new(),
    }
}

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

    #[test]
    fn both_editions_load() {
        let table = AbrechnungsdatenProdukte::embedded();
        assert_eq!(table.products(Edition::V1_3c).len(), 9);
        assert_eq!(table.products(Edition::V1_4).len(), 10);
        assert!(table.product(Edition::V1_3c, "9991000003246").is_none());
        let arbeit = table.product(Edition::V1_4, "9991000003246").expect("1.4");
        assert_eq!(arbeit.name, "spezifische Arbeit");
    }

    #[test]
    fn wertedetails_hold_their_formats() {
        let table = AbrechnungsdatenProdukte::embedded();
        let w = |code: &str| {
            table
                .product(Edition::V1_4, code)
                .and_then(|p| p.wertedetails.as_ref())
                .expect("a Wertedetail")
        };
        let bilanzkreis = w("9991000002553");
        assert_eq!(bilanzkreis.fits("11XLIEFERANT2--E"), ConditionResult::True);
        assert_eq!(
            bilanzkreis.fits("11XLIEFERANT2--E-TOO-LONG"),
            ConditionResult::False
        );
        let prognose = w("9991000002561");
        assert_eq!(prognose.fits("4000"), ConditionResult::True);
        assert_eq!(prognose.fits("4000.5"), ConditionResult::False);
        assert_eq!(prognose.fits("-1"), ConditionResult::False);
        assert_eq!(prognose.fits("11XLIEFERANT2--E"), ConditionResult::False);
        let arbeit = w("9991000003246");
        assert_eq!(arbeit.fits("1.125"), ConditionResult::True);
        assert_eq!(arbeit.fits("1.1255"), ConditionResult::False);
        assert_eq!(arbeit.fits("-0.5"), ConditionResult::False);
    }
}