1use mig_types::segment::OwnedSegment;
18
19use crate::validator::{IssueKind, SegmentPosition, Severity, ValidationIssue};
20
21pub const INTERVALS: &[(&str, &str, bool)] = &[
27 ("92", "93", false), ("155", "156", false), ("158", "159", false), ("163", "164", false), ("Z15", "Z16", false), ("Z25", "Z26", false), ("Z34", "Z35", false), ("Z42", "Z43", false), ("469", "472", true), ];
37
38pub const REFERENCES: &[(&str, &[&str])] = &[
43 ("Z18", &["Z16", "Z21", "Z22"]), ("Z19", &["Z17"]), ("Z32", &["Z18"]), ("Z37", &["Z20"]), ("Z38", &["Z19"]), ];
49
50pub const REFERENCES_GAS: &[(&str, &[&str])] = &[("Z19", &["172"])];
55
56pub fn check_consistency(segments: &[OwnedSegment], message_type: &str) -> Vec<ValidationIssue> {
59 let mut out = intervals(segments);
60 match message_type {
61 "UTILMD_Strom" => out.extend(references(segments, REFERENCES)),
62 "UTILMD_Gas" => out.extend(references(segments, REFERENCES_GAS)),
63 _ => {}
64 }
65 out.sort_by_key(|i| i.segment_position.map(|p| p.segment_number));
66 out
67}
68
69fn instant(value: &str, format: &str) -> Option<i64> {
72 let digits = |s: &str| -> Option<i64> {
73 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
74 return None;
75 }
76 s.parse().ok()
77 };
78 let (date, minutes, offset) = match format {
79 "102" if value.len() == 8 => (value, 0, 0),
80 "303" if value.len() == 15 => {
81 let hours = digits(&value[8..10])?;
82 let mins = digits(&value[10..12])?;
83 let sign = match &value[12..13] {
84 "+" => 1,
85 "-" => -1,
86 _ => return None,
87 };
88 (
89 &value[..8],
90 hours * 60 + mins,
91 sign * digits(&value[13..15])? * 60,
92 )
93 }
94 _ => return None,
95 };
96 let (y, m, d) = (
97 digits(&date[..4])?,
98 digits(&date[4..6])?,
99 digits(&date[6..8])?,
100 );
101 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
102 return None;
103 }
104 let y = if m <= 2 { y - 1 } else { y };
106 let era = y.div_euclid(400);
107 let yoe = y - era * 400;
108 let mp = (m + 9) % 12;
109 let doy = (153 * mp + 2) / 5 + d - 1;
110 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
111 let days = era * 146_097 + doe;
112 Some(days * 24 * 60 + minutes - offset)
113}
114
115fn intervals(segments: &[OwnedSegment]) -> Vec<ValidationIssue> {
116 let mut out = Vec::new();
117 for run in segments.split(|s| s.id != "DTM") {
120 for &(begin, end, may_be_empty) in INTERVALS {
121 let find = |q: &str| run.iter().find(|s| s.get_component(0, 0) == q);
122 let (Some(b), Some(e)) = (find(begin), find(end)) else {
123 continue;
124 };
125 let format = b.get_component(0, 2);
126 if format != e.get_component(0, 2) {
127 continue;
128 }
129 let (Some(from), Some(to)) = (
130 instant(b.get_component(0, 1), format),
131 instant(e.get_component(0, 1), format),
132 ) else {
133 continue;
134 };
135 if to < from || (to == from && !may_be_empty) {
136 out.push(
137 ValidationIssue::new(
138 Severity::Error,
139 IssueKind::IntervalNotPositive {
140 begin: format!("DTM+{begin}"),
141 end: format!("DTM+{end}"),
142 },
143 )
144 .with_position(SegmentPosition::at(e.segment_number))
145 .with_field_path("DTM/C507/2380")
146 .with_field_position(2, Some(2))
147 .with_actual(e.get_component(0, 1))
148 .with_expected(format!("> {}", b.get_component(0, 1))),
149 );
150 }
151 }
152 }
153 out
154}
155
156fn references(segments: &[OwnedSegment], table: &[(&str, &[&str])]) -> Vec<ValidationIssue> {
157 let mut out = Vec::new();
158 for transaction in segments.split(|s| s.id == "IDE").skip(1) {
161 let mut locs: Vec<(&str, &str)> = Vec::new();
162 let mut in_sg8_or_sg12 = false;
163 for seg in transaction {
164 match seg.id.as_str() {
165 "LOC" if !in_sg8_or_sg12 => {
166 locs.push((seg.get_element(0), seg.get_component(1, 0)));
167 }
168 "SEQ" | "NAD" => in_sg8_or_sg12 = true,
169 "RFF" if in_sg8_or_sg12 => {
170 let qualifier = seg.get_component(0, 0);
171 let id = seg.get_component(0, 1);
172 let Some((_, kinds)) = table.iter().find(|(q, _)| *q == qualifier) else {
173 continue;
174 };
175 let named: Vec<&str> = locs
176 .iter()
177 .filter(|(k, v)| kinds.contains(k) && !v.is_empty())
178 .map(|(_, v)| *v)
179 .collect();
180 if id.is_empty() || named.is_empty() || named.contains(&id) {
181 continue;
182 }
183 out.push(
184 ValidationIssue::new(
185 Severity::Error,
186 IssueKind::ReferenceNotFound {
187 reference: format!("RFF+{qualifier}"),
188 value: id.to_string(),
189 targets: kinds.iter().map(|k| format!("LOC+{k}")).collect(),
190 },
191 )
192 .with_position(SegmentPosition::at(seg.segment_number))
193 .with_field_path("RFF/C506/1154")
194 .with_field_position(2, Some(2))
195 .with_actual(id)
196 .with_expected(named.join(", ")),
197 );
198 }
199 _ => {}
200 }
201 }
202 }
203 out
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 fn message(text: &str) -> Vec<OwnedSegment> {
211 mig_assembly::tokenize::parse_to_segments(text.as_bytes()).unwrap()
212 }
213
214 #[test]
215 fn instants_compare_across_offsets() {
216 let a = instant("202501010000+00", "303").unwrap();
217 let b = instant("202501010100+01", "303").unwrap();
218 assert_eq!(a, b);
219 assert!(instant("20250301", "102").unwrap() > instant("20250228", "102").unwrap());
220 assert_eq!(instant("2025013", "102"), None);
221 assert_eq!(instant("202501010000+00", "610"), None);
222 }
223
224 #[test]
225 fn a_period_ending_as_or_before_it_begins_is_z34() {
226 let issues = |dtm: &str| {
227 check_consistency(
228 &message(&format!(
229 "UNH+1+MSCONS:D:04B:UN:2.5'QTY+220:1'{dtm}UNT+5+1'"
230 )),
231 "MSCONS",
232 )
233 };
234 let backwards = issues("DTM+163:202501020000?+00:303'DTM+164:202501010000?+00:303'");
235 assert_eq!(backwards.len(), 1, "{backwards:?}");
236 assert_eq!(backwards[0].code(), "CON001");
237 assert_eq!(
238 backwards[0].segment_position.map(|p| p.segment_number),
239 Some(4)
240 );
241 assert_eq!(
242 issues("DTM+163:202501010000?+00:303'DTM+164:202501010000?+00:303'").len(),
243 1
244 );
245 assert!(issues("DTM+163:202501010000?+00:303'DTM+164:202501020000?+00:303'").is_empty());
246 assert!(issues("DTM+163:20250102:102'DTM+164:202501010000?+00:303'").is_empty());
248 assert!(
249 issues("DTM+163:202501020000?+00:303'QTY+220:1'DTM+164:202501010000?+00:303'")
250 .is_empty()
251 );
252 assert!(issues("DTM+469:202501010000?+00:303'DTM+472:202501010000?+00:303'").is_empty());
254 }
255
256 #[test]
257 fn a_reference_to_no_location_of_its_transaction_is_z21() {
258 let issues = |rff: &str| {
259 check_consistency(
260 &message(&format!(
261 "UNH+1+UTILMD:D:11A:UN:S2.2'IDE+24+X'LOC+Z17+DE00MELO'LOC+Z18+E1814774964'\
262 SEQ+Z03'{rff}UNT+7+1'"
263 )),
264 "UTILMD_Strom",
265 )
266 };
267 let wrong = issues("RFF+Z19:DE00ANDERE'");
268 assert_eq!(wrong.len(), 1, "{wrong:?}");
269 assert_eq!(wrong[0].code(), "CON002");
270 assert!(issues("RFF+Z19:DE00MELO'").is_empty());
271 assert!(issues("RFF+Z37:C123'").is_empty());
273 let gas = |rff: &str| {
275 check_consistency(
276 &message(&format!(
277 "UNH+1+UTILMD:D:11A:UN:G1.2'IDE+24+X'LOC+172+DE00MELO'SEQ+Z03'{rff}UNT+6+1'"
278 )),
279 "UTILMD_Gas",
280 )
281 };
282 assert_eq!(gas("RFF+Z19:DE00ANDERE'").len(), 1);
283 assert!(gas("RFF+Z19:DE00MELO'").is_empty());
284 assert!(check_consistency(
286 &message("UNH+1+UTILTS:D:18A:UN:1.1e'IDE+24+X'LOC+Z17+A'SEQ+Z03'RFF+Z19:B'UNT+6+1'"),
287 "UTILTS"
288 )
289 .is_empty());
290 }
291}