Skip to main content

automapper_validation/eval/
abrechnungsdaten.rs

1//! The products of chapter 6.2 of the Codeliste der Konfigurationen,
2//! "Produkte zur Bestellung einer Änderung von Abrechnungsdaten": what an
3//! ORDERS 17133 may order in PIA+5 DE7140, and what each product takes in its
4//! SG30 — a Produkteigenschaft (CAV+ZH9 DE7110) from its own list, or a
5//! Wertedetail (CAV+ZV4 DE7110) of its own format.
6//!
7//! The AHB conditions reading it: `[158]` (the product is one of them),
8//! `[159]` (it has Produkteigenschaften), `[160]` (the CAV+ZH9 code is one of
9//! its own), `[162]` (the CAV+ZV4 value fits its format) and `[180]` (it has a
10//! Wertedetail). Bilanzkreis takes an EIC (`[970]` an..17),
11//! Jahresverbrauchsprognose a whole number (`[909]` ∧ `[937]`).
12//!
13//! Data source: the Codeliste's .docx editions, extracted with
14//! `scripts/extract_konfigurationen_abrechnungsdaten.py`. FV2504 to FV2604
15//! read version 1.3c, FV2610 version 1.4 (which adds spezifische Arbeit).
16
17use serde::Deserialize;
18use std::collections::HashMap;
19use std::sync::OnceLock;
20
21use super::{ConditionResult, EvaluationContext};
22use crate::eval::format_validators::{is_edifact_number, validate_max_decimal_places};
23
24const EMBEDDED_JSON: &str = include_str!("../../data/konfigurationen_abrechnungsdaten.json");
25
26/// The edition of the Codeliste der Konfigurationen a format version reads.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Edition {
29    /// Version 1.3c: FV2504, FV2510, FV2604.
30    V1_3c,
31    /// Version 1.4: FV2610.
32    V1_4,
33}
34
35impl Edition {
36    /// The edition `fv` reads; `None` for a format version before FV2504.
37    pub fn of_format_version(fv: &str) -> Option<Self> {
38        match fv {
39            "FV2504" | "FV2510" | "FV2604" => Some(Self::V1_3c),
40            "FV2610" => Some(Self::V1_4),
41            _ => None,
42        }
43    }
44
45    fn key(self) -> &'static str {
46        match self {
47            Self::V1_3c => "1.3c",
48            Self::V1_4 => "1.4",
49        }
50    }
51}
52
53#[derive(Debug, Deserialize)]
54struct RawFile {
55    versions: HashMap<String, RawVersion>,
56}
57
58#[derive(Debug, Deserialize)]
59struct RawVersion {
60    products: Vec<Product>,
61}
62
63/// One product of chapter 6.2.
64#[derive(Debug, Deserialize)]
65pub struct Product {
66    /// The product code, PIA+5 DE7140.
67    pub code: String,
68    pub name: String,
69    /// The codes its CAV+ZH9 DE7110 may carry; empty when it has none.
70    pub property_codes: Vec<PropertyCode>,
71    /// What its CAV+ZV4 DE7110 carries; `None` when it has none.
72    pub wertedetails: Option<Wertedetails>,
73}
74
75#[derive(Debug, Deserialize)]
76pub struct PropertyCode {
77    pub code: String,
78    pub name: String,
79}
80
81/// A product's Wertedetail: its name and the format conditions its value
82/// must hold (`[970]`).
83#[derive(Debug, Deserialize)]
84pub struct Wertedetails {
85    pub name: String,
86    pub conditions: Vec<u32>,
87}
88
89impl Wertedetails {
90    /// Whether `value` holds every format condition. Unknown for a condition
91    /// the table names but this code does not know.
92    pub fn fits(&self, value: &str) -> ConditionResult {
93        let mut result = ConditionResult::True;
94        for condition in &self.conditions {
95            let holds = match condition {
96                // Format: an..17
97                970 => ConditionResult::from(value.chars().count() <= 17),
98                // Format: Mögliche Werte: 0 bis n
99                909 => ConditionResult::from(
100                    is_edifact_number(value) && !value.trim_start().starts_with('-'),
101                ),
102                // Format: keine Nachkommastelle
103                937 => {
104                    ConditionResult::from(is_edifact_number(value) && !value.contains(['.', ',']))
105                }
106                // Format: max. 3 Nachkommastellen
107                906 => validate_max_decimal_places(value, 3),
108                _ => ConditionResult::Unknown,
109            };
110            match holds {
111                ConditionResult::False => return ConditionResult::False,
112                ConditionResult::Unknown => result = ConditionResult::Unknown,
113                ConditionResult::True => {}
114            }
115        }
116        result
117    }
118}
119
120/// Chapter 6.2 in every edition the format versions read.
121#[derive(Debug)]
122pub struct AbrechnungsdatenProdukte {
123    editions: HashMap<String, Vec<Product>>,
124}
125
126impl AbrechnungsdatenProdukte {
127    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
128        let raw: RawFile = serde_json::from_str(json)?;
129        Ok(Self {
130            editions: raw
131                .versions
132                .into_iter()
133                .map(|(version, v)| (version, v.products))
134                .collect(),
135        })
136    }
137
138    pub fn embedded() -> &'static Self {
139        static CELL: OnceLock<AbrechnungsdatenProdukte> = OnceLock::new();
140        CELL.get_or_init(|| {
141            AbrechnungsdatenProdukte::from_json(EMBEDDED_JSON)
142                .expect("embedded konfigurationen_abrechnungsdaten.json is malformed")
143        })
144    }
145
146    /// The products of `edition`, in the table's order.
147    pub fn products(&self, edition: Edition) -> &[Product] {
148        self.editions
149            .get(edition.key())
150            .map_or(&[], |products| products.as_slice())
151    }
152
153    /// The product `code` of `edition`, if the table lists it.
154    pub fn product(&self, edition: Edition, code: &str) -> Option<&Product> {
155        self.products(edition).iter().find(|p| p.code == code)
156    }
157}
158
159/// The product code ordered in PIA+5 DE7140 of the SG29 the field sits in,
160/// or of the message's first PIA+5 without a scope (17133 sends one SG29,
161/// `[2050]`). `None` when there is none.
162fn ordered_product(ctx: &EvaluationContext) -> Option<String> {
163    ctx.scoped_find_segments_in("SG29", "PIA")
164        .into_iter()
165        .find(|s| s.get_element(0) == "5")
166        .and_then(|s| s.elements.get(1)?.first().cloned())
167        .filter(|code| !code.is_empty())
168}
169
170/// What `answer` makes of the ordered product: Unknown when there is none,
171/// False for a product the table does not list.
172fn of_product(
173    ctx: &EvaluationContext,
174    edition: Edition,
175    answer: impl Fn(&Product) -> ConditionResult,
176) -> ConditionResult {
177    let Some(code) = ordered_product(ctx) else {
178        return ConditionResult::Unknown;
179    };
180    match AbrechnungsdatenProdukte::embedded().product(edition, &code) {
181        Some(product) => answer(product),
182        None => ConditionResult::False,
183    }
184}
185
186/// `[158]` Es sind nur die Produkt-Codes erlaubt, die in Kapitel 6.2 genannt
187/// sind: judged on the element's own value (PIA+5 DE7140), else on the
188/// ordered product.
189pub fn product_is_listed(ctx: &EvaluationContext, edition: Edition) -> ConditionResult {
190    let code = match ctx.resolved_value {
191        Some(value) => value.to_string(),
192        None => match ordered_product(ctx) {
193            Some(code) => code,
194            None => return ConditionResult::Unknown,
195        },
196    };
197    ConditionResult::from(
198        AbrechnungsdatenProdukte::embedded()
199            .product(edition, &code)
200            .is_some(),
201    )
202}
203
204/// `[159]` Wenn der Produkt-Code in der Spalte "Code der Produkteigenschaft"
205/// mit einem Code befüllt ist.
206pub fn has_property_codes(ctx: &EvaluationContext, edition: Edition) -> ConditionResult {
207    of_product(ctx, edition, |p| {
208        ConditionResult::from(!p.property_codes.is_empty())
209    })
210}
211
212/// `[160]` Nur die Codes der Produkteigenschaft des Produkts: the value of
213/// CAV+ZH9 DE7110 is one of its codes.
214pub fn property_code_allowed(ctx: &EvaluationContext, edition: Edition) -> ConditionResult {
215    let Some(value) = ctx.resolved_value else {
216        return ConditionResult::Unknown;
217    };
218    of_product(ctx, edition, |p| {
219        ConditionResult::from(p.property_codes.iter().any(|c| c.code == value))
220    })
221}
222
223/// `[162]` Nur der Wertebereich der Spalte "Wertedetails für Position": the
224/// value of CAV+ZV4 DE7110 holds the product's format conditions. False for a
225/// product without a Wertedetail.
226pub fn wertedetails_value_fits(ctx: &EvaluationContext, edition: Edition) -> ConditionResult {
227    let Some(value) = ctx.resolved_value else {
228        return ConditionResult::Unknown;
229    };
230    of_product(ctx, edition, |p| match &p.wertedetails {
231        Some(w) => w.fits(value),
232        None => ConditionResult::False,
233    })
234}
235
236/// `[180]` Wenn der Produkt-Code in der Spalte "Wertedetails für Position"
237/// eine Angabe hat, deren ggf. enthaltene Bedingung erfüllt ist. The
238/// Bedingungen there are the value's formats, which `[162]` beside it judges;
239/// here, that the product has a Wertedetail.
240pub fn has_wertedetails(ctx: &EvaluationContext, edition: Edition) -> ConditionResult {
241    of_product(ctx, edition, |p| {
242        ConditionResult::from(p.wertedetails.is_some())
243    })
244}
245
246/// Values that satisfy the ORDERS `condition` of `fv` for some product of
247/// chapter 6.2 — every product code for `[158]`, every Produkteigenschaft for
248/// `[160]` — for a sample generator to offer an element the condition judges.
249/// Empty for any other condition or message type.
250pub fn candidate_values(message_type: &str, fv: &str, condition: u32) -> Vec<String> {
251    let Some(edition) = Edition::of_format_version(fv).filter(|_| message_type == "ORDERS") else {
252        return Vec::new();
253    };
254    let products = AbrechnungsdatenProdukte::embedded().products(edition);
255    match condition {
256        158 => products.iter().map(|p| p.code.clone()).collect(),
257        160 => products
258            .iter()
259            .flat_map(|p| p.property_codes.iter().map(|c| c.code.clone()))
260            .collect(),
261        _ => Vec::new(),
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn both_editions_load() {
271        let table = AbrechnungsdatenProdukte::embedded();
272        assert_eq!(table.products(Edition::V1_3c).len(), 9);
273        assert_eq!(table.products(Edition::V1_4).len(), 10);
274        assert!(table.product(Edition::V1_3c, "9991000003246").is_none());
275        let arbeit = table.product(Edition::V1_4, "9991000003246").expect("1.4");
276        assert_eq!(arbeit.name, "spezifische Arbeit");
277    }
278
279    #[test]
280    fn wertedetails_hold_their_formats() {
281        let table = AbrechnungsdatenProdukte::embedded();
282        let w = |code: &str| {
283            table
284                .product(Edition::V1_4, code)
285                .and_then(|p| p.wertedetails.as_ref())
286                .expect("a Wertedetail")
287        };
288        let bilanzkreis = w("9991000002553");
289        assert_eq!(bilanzkreis.fits("11XLIEFERANT2--E"), ConditionResult::True);
290        assert_eq!(
291            bilanzkreis.fits("11XLIEFERANT2--E-TOO-LONG"),
292            ConditionResult::False
293        );
294        let prognose = w("9991000002561");
295        assert_eq!(prognose.fits("4000"), ConditionResult::True);
296        assert_eq!(prognose.fits("4000.5"), ConditionResult::False);
297        assert_eq!(prognose.fits("-1"), ConditionResult::False);
298        assert_eq!(prognose.fits("11XLIEFERANT2--E"), ConditionResult::False);
299        let arbeit = w("9991000003246");
300        assert_eq!(arbeit.fits("1.125"), ConditionResult::True);
301        assert_eq!(arbeit.fits("1.1255"), ConditionResult::False);
302        assert_eq!(arbeit.fits("-0.5"), ConditionResult::False);
303    }
304}