|
| 1 | +//! DATEV SKR (Standardkontenrahmen) -> `ContextBundle` hydrator. |
| 2 | +//! |
| 3 | +//! The Standardkontenrahmen is the German de-facto chart of accounts used |
| 4 | +//! for HGB / EStG-compliant bookkeeping. Two parallel schemes coexist: |
| 5 | +//! |
| 6 | +//! - **SKR 03** (Prozessgliederungsprinzip) — process-oriented family |
| 7 | +//! numbering; used by most German SMEs. |
| 8 | +//! - **SKR 04** (Abschlussgliederungsprinzip) — balance-sheet-oriented |
| 9 | +//! numbering aligned with HGB §266 P&L structure; used by larger firms. |
| 10 | +//! |
| 11 | +//! The two schemes are NOT interchangeable: account `1000` means |
| 12 | +//! "Roh-, Hilfs- und Betriebsstoffe" in SKR 04 but "Kasse" in SKR 03. |
| 13 | +//! They therefore hydrate into separate G slots |
| 14 | +//! (`OGIT::SKR03_V1` and `OGIT::SKR04_V1`). |
| 15 | +//! |
| 16 | +//! Pattern D, minimal-name-extraction shape: |
| 17 | +//! |
| 18 | +//! Each row in the source CSV (`data/ontologies/skr-datev/skr0{3,4}.csv`) |
| 19 | +//! contributes ONE IRI: `urn:datev:skr0{3,4}:account/{number}`. Family |
| 20 | +//! classifications (Anlagevermögen, Erlöse, etc.) are NOT projected as |
| 21 | +//! separate IRIs in this minimal hydrator — they live in the CSV's |
| 22 | +//! `family` column and can be lifted to a SKOS Collection axiom in a |
| 23 | +//! follow-up PR. |
| 24 | +//! |
| 25 | +//! CSV format: |
| 26 | +//! |
| 27 | +//! ```csv |
| 28 | +//! account_number,account_name,family,source |
| 29 | +//! 0050,"Ausstehende Einlagen auf das ...",Anlagevermögen,"DATEV SKR 04 ..." |
| 30 | +//! ``` |
| 31 | +//! |
| 32 | +//! Quoted fields (containing commas / quotes) are supported; the CSV is |
| 33 | +//! parsed by a small streaming state machine to avoid pulling a new dep. |
| 34 | +
|
| 35 | +use std::collections::HashMap; |
| 36 | +use std::fs; |
| 37 | +use std::path::Path; |
| 38 | +use std::sync::Arc; |
| 39 | + |
| 40 | +use super::owl::{ContextBundle, EntityId, HydrateErr, OntologySlot}; |
| 41 | +use crate::registry::OntologyRegistry; |
| 42 | + |
| 43 | +/// Hydrator for a DATEV SKR CSV. One instance hydrates one `ContextBundle` |
| 44 | +/// keyed by `g`, drawing rows from a single CSV file. `iri_prefix` is the |
| 45 | +/// URN base each row's `account_number` is appended to. |
| 46 | +pub struct SkrHydrator { |
| 47 | + pub g: u32, |
| 48 | + pub version: u32, |
| 49 | + pub domain_name: String, |
| 50 | + pub inherits_from: Option<u32>, |
| 51 | + pub starting_entity_id: EntityId, |
| 52 | + /// e.g. `"urn:datev:skr04:account"` — the `/{account_number}` is appended. |
| 53 | + pub iri_prefix: String, |
| 54 | +} |
| 55 | + |
| 56 | +impl SkrHydrator { |
| 57 | + /// Read the CSV at `csv_path` and intern each row's account number as |
| 58 | + /// `{iri_prefix}/{account_number}`. |
| 59 | + pub fn hydrate( |
| 60 | + &self, |
| 61 | + csv_path: &Path, |
| 62 | + registry: &OntologyRegistry, |
| 63 | + ) -> Result<u32, HydrateErr> { |
| 64 | + let bytes = fs::read(csv_path).map_err(|e| HydrateErr::Io { |
| 65 | + path: csv_path.to_path_buf(), |
| 66 | + source: e, |
| 67 | + })?; |
| 68 | + let text = std::str::from_utf8(&bytes).map_err(|e| HydrateErr::Parse { |
| 69 | + path: csv_path.to_path_buf(), |
| 70 | + message: format!("CSV not UTF-8: {e}"), |
| 71 | + })?; |
| 72 | + |
| 73 | + let mut iri_to_id: HashMap<String, EntityId> = HashMap::new(); |
| 74 | + let mut next_id: EntityId = self.starting_entity_id; |
| 75 | + |
| 76 | + let mut header_seen = false; |
| 77 | + for line in text.lines() { |
| 78 | + if line.is_empty() { |
| 79 | + continue; |
| 80 | + } |
| 81 | + if !header_seen { |
| 82 | + header_seen = true; |
| 83 | + continue; |
| 84 | + } |
| 85 | + let fields = parse_csv_line(line); |
| 86 | + if fields.is_empty() { |
| 87 | + continue; |
| 88 | + } |
| 89 | + let account_number = fields[0].trim(); |
| 90 | + if account_number.is_empty() { |
| 91 | + continue; |
| 92 | + } |
| 93 | + let iri = format!("{}/{}", self.iri_prefix, account_number); |
| 94 | + if !iri_to_id.contains_key(&iri) { |
| 95 | + let id = next_id; |
| 96 | + next_id += 1; |
| 97 | + iri_to_id.insert(iri, id); |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + let entity_count = iri_to_id.len() as u32; |
| 102 | + let ontology = OntologySlot { |
| 103 | + entity_count, |
| 104 | + iri_to_id, |
| 105 | + }; |
| 106 | + let bundle = ContextBundle { |
| 107 | + g: self.g, |
| 108 | + version: self.version, |
| 109 | + domain_name: self.domain_name.clone(), |
| 110 | + inherits_from: self.inherits_from, |
| 111 | + ontology: Some(Arc::new(ontology)), |
| 112 | + edge_types: Vec::new(), |
| 113 | + }; |
| 114 | + registry.register_bundle(bundle); |
| 115 | + Ok(self.g) |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +/// Minimal CSV-line parser. Handles double-quoted fields with embedded commas |
| 120 | +/// and the `""` escape for a literal quote inside a quoted field. Does NOT |
| 121 | +/// handle multi-line records (those don't occur in DATEV SKR CSVs). |
| 122 | +fn parse_csv_line(line: &str) -> Vec<String> { |
| 123 | + let mut out: Vec<String> = Vec::new(); |
| 124 | + let mut cur = String::new(); |
| 125 | + let mut in_quotes = false; |
| 126 | + let mut chars = line.chars().peekable(); |
| 127 | + while let Some(c) = chars.next() { |
| 128 | + if in_quotes { |
| 129 | + if c == '"' { |
| 130 | + // `""` -> literal `"`. |
| 131 | + if chars.peek() == Some(&'"') { |
| 132 | + chars.next(); |
| 133 | + cur.push('"'); |
| 134 | + } else { |
| 135 | + in_quotes = false; |
| 136 | + } |
| 137 | + } else { |
| 138 | + cur.push(c); |
| 139 | + } |
| 140 | + } else if c == '"' { |
| 141 | + in_quotes = true; |
| 142 | + } else if c == ',' { |
| 143 | + out.push(std::mem::take(&mut cur)); |
| 144 | + } else { |
| 145 | + cur.push(c); |
| 146 | + } |
| 147 | + } |
| 148 | + out.push(cur); |
| 149 | + out |
| 150 | +} |
| 151 | + |
| 152 | +#[cfg(test)] |
| 153 | +mod tests { |
| 154 | + use super::*; |
| 155 | + use std::io::Write; |
| 156 | + use tempfile::tempdir; |
| 157 | + |
| 158 | + const TINY_CSV: &str = r#"account_number,account_name,family,source |
| 159 | +0001,"First, account",Anlagevermögen,test |
| 160 | +0002,Second account,Umlaufvermögen,test |
| 161 | +0003,"He said ""hi""",Eigenkapital,test |
| 162 | +"#; |
| 163 | + |
| 164 | + #[test] |
| 165 | + fn parse_csv_line_handles_quoted_commas_and_escapes() { |
| 166 | + let f = parse_csv_line(r#"0001,"First, account",Anlagevermögen,test"#); |
| 167 | + assert_eq!(f, vec!["0001", "First, account", "Anlagevermögen", "test"]); |
| 168 | + |
| 169 | + let f = parse_csv_line(r#"0003,"He said ""hi""",Eigenkapital,test"#); |
| 170 | + assert_eq!(f, vec!["0003", r#"He said "hi""#, "Eigenkapital", "test"]); |
| 171 | + |
| 172 | + let f = parse_csv_line(r#"plain,unquoted,row,end"#); |
| 173 | + assert_eq!(f, vec!["plain", "unquoted", "row", "end"]); |
| 174 | + } |
| 175 | + |
| 176 | + #[test] |
| 177 | + fn tiny_csv_hydrates_to_three_iris() { |
| 178 | + let dir = tempdir().unwrap(); |
| 179 | + let path = dir.path().join("tiny.csv"); |
| 180 | + let mut f = std::fs::File::create(&path).unwrap(); |
| 181 | + f.write_all(TINY_CSV.as_bytes()).unwrap(); |
| 182 | + drop(f); |
| 183 | + |
| 184 | + let reg = OntologyRegistry::new_in_memory(); |
| 185 | + let h = SkrHydrator { |
| 186 | + g: 7777, |
| 187 | + version: 1, |
| 188 | + domain_name: "tiny-skr".to_string(), |
| 189 | + inherits_from: None, |
| 190 | + starting_entity_id: 100, |
| 191 | + iri_prefix: "urn:test:skr".to_string(), |
| 192 | + }; |
| 193 | + h.hydrate(&path, ®).expect("hydrate tiny SKR CSV"); |
| 194 | + |
| 195 | + let bundle = reg.bundle_for(7777).expect("bundle"); |
| 196 | + assert_eq!(bundle.entity_count(), 3); |
| 197 | + assert!(bundle.resolve_iri("urn:test:skr/0001").is_some()); |
| 198 | + assert!(bundle.resolve_iri("urn:test:skr/0002").is_some()); |
| 199 | + assert!(bundle.resolve_iri("urn:test:skr/0003").is_some()); |
| 200 | + } |
| 201 | +} |
0 commit comments