use mig_types::segment::OwnedSegment;
use crate::validator::{IssueKind, SegmentPosition, Severity, ValidationIssue};
pub const INTERVALS: &[(&str, &str, bool)] = &[
("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), ];
pub const REFERENCES: &[(&str, &[&str])] = &[
("Z18", &["Z16", "Z21", "Z22"]), ("Z19", &["Z17"]), ("Z32", &["Z18"]), ("Z37", &["Z20"]), ("Z38", &["Z19"]), ];
pub const REFERENCES_GAS: &[(&str, &[&str])] = &[("Z19", &["172"])];
pub fn check_consistency(segments: &[OwnedSegment], message_type: &str) -> Vec<ValidationIssue> {
let mut out = intervals(segments);
match message_type {
"UTILMD_Strom" => out.extend(references(segments, REFERENCES)),
"UTILMD_Gas" => out.extend(references(segments, REFERENCES_GAS)),
_ => {}
}
out.sort_by_key(|i| i.segment_position.map(|p| p.segment_number));
out
}
fn instant(value: &str, format: &str) -> Option<i64> {
let digits = |s: &str| -> Option<i64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
};
let (date, minutes, offset) = match format {
"102" if value.len() == 8 => (value, 0, 0),
"303" if value.len() == 15 => {
let hours = digits(&value[8..10])?;
let mins = digits(&value[10..12])?;
let sign = match &value[12..13] {
"+" => 1,
"-" => -1,
_ => return None,
};
(
&value[..8],
hours * 60 + mins,
sign * digits(&value[13..15])? * 60,
)
}
_ => return None,
};
let (y, m, d) = (
digits(&date[..4])?,
digits(&date[4..6])?,
digits(&date[6..8])?,
);
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe;
Some(days * 24 * 60 + minutes - offset)
}
fn intervals(segments: &[OwnedSegment]) -> Vec<ValidationIssue> {
let mut out = Vec::new();
for run in segments.split(|s| s.id != "DTM") {
for &(begin, end, may_be_empty) in INTERVALS {
let find = |q: &str| run.iter().find(|s| s.get_component(0, 0) == q);
let (Some(b), Some(e)) = (find(begin), find(end)) else {
continue;
};
let format = b.get_component(0, 2);
if format != e.get_component(0, 2) {
continue;
}
let (Some(from), Some(to)) = (
instant(b.get_component(0, 1), format),
instant(e.get_component(0, 1), format),
) else {
continue;
};
if to < from || (to == from && !may_be_empty) {
out.push(
ValidationIssue::new(
Severity::Error,
IssueKind::IntervalNotPositive {
begin: format!("DTM+{begin}"),
end: format!("DTM+{end}"),
},
)
.with_position(SegmentPosition::at(e.segment_number))
.with_field_path("DTM/C507/2380")
.with_field_position(2, Some(2))
.with_actual(e.get_component(0, 1))
.with_expected(format!("> {}", b.get_component(0, 1))),
);
}
}
}
out
}
fn references(segments: &[OwnedSegment], table: &[(&str, &[&str])]) -> Vec<ValidationIssue> {
let mut out = Vec::new();
for transaction in segments.split(|s| s.id == "IDE").skip(1) {
let mut locs: Vec<(&str, &str)> = Vec::new();
let mut in_sg8_or_sg12 = false;
for seg in transaction {
match seg.id.as_str() {
"LOC" if !in_sg8_or_sg12 => {
locs.push((seg.get_element(0), seg.get_component(1, 0)));
}
"SEQ" | "NAD" => in_sg8_or_sg12 = true,
"RFF" if in_sg8_or_sg12 => {
let qualifier = seg.get_component(0, 0);
let id = seg.get_component(0, 1);
let Some((_, kinds)) = table.iter().find(|(q, _)| *q == qualifier) else {
continue;
};
let named: Vec<&str> = locs
.iter()
.filter(|(k, v)| kinds.contains(k) && !v.is_empty())
.map(|(_, v)| *v)
.collect();
if id.is_empty() || named.is_empty() || named.contains(&id) {
continue;
}
out.push(
ValidationIssue::new(
Severity::Error,
IssueKind::ReferenceNotFound {
reference: format!("RFF+{qualifier}"),
value: id.to_string(),
targets: kinds.iter().map(|k| format!("LOC+{k}")).collect(),
},
)
.with_position(SegmentPosition::at(seg.segment_number))
.with_field_path("RFF/C506/1154")
.with_field_position(2, Some(2))
.with_actual(id)
.with_expected(named.join(", ")),
);
}
_ => {}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn message(text: &str) -> Vec<OwnedSegment> {
mig_assembly::tokenize::parse_to_segments(text.as_bytes()).unwrap()
}
#[test]
fn instants_compare_across_offsets() {
let a = instant("202501010000+00", "303").unwrap();
let b = instant("202501010100+01", "303").unwrap();
assert_eq!(a, b);
assert!(instant("20250301", "102").unwrap() > instant("20250228", "102").unwrap());
assert_eq!(instant("2025013", "102"), None);
assert_eq!(instant("202501010000+00", "610"), None);
}
#[test]
fn a_period_ending_as_or_before_it_begins_is_z34() {
let issues = |dtm: &str| {
check_consistency(
&message(&format!(
"UNH+1+MSCONS:D:04B:UN:2.5'QTY+220:1'{dtm}UNT+5+1'"
)),
"MSCONS",
)
};
let backwards = issues("DTM+163:202501020000?+00:303'DTM+164:202501010000?+00:303'");
assert_eq!(backwards.len(), 1, "{backwards:?}");
assert_eq!(backwards[0].code(), "CON001");
assert_eq!(
backwards[0].segment_position.map(|p| p.segment_number),
Some(4)
);
assert_eq!(
issues("DTM+163:202501010000?+00:303'DTM+164:202501010000?+00:303'").len(),
1
);
assert!(issues("DTM+163:202501010000?+00:303'DTM+164:202501020000?+00:303'").is_empty());
assert!(issues("DTM+163:20250102:102'DTM+164:202501010000?+00:303'").is_empty());
assert!(
issues("DTM+163:202501020000?+00:303'QTY+220:1'DTM+164:202501010000?+00:303'")
.is_empty()
);
assert!(issues("DTM+469:202501010000?+00:303'DTM+472:202501010000?+00:303'").is_empty());
}
#[test]
fn a_reference_to_no_location_of_its_transaction_is_z21() {
let issues = |rff: &str| {
check_consistency(
&message(&format!(
"UNH+1+UTILMD:D:11A:UN:S2.2'IDE+24+X'LOC+Z17+DE00MELO'LOC+Z18+E1814774964'\
SEQ+Z03'{rff}UNT+7+1'"
)),
"UTILMD_Strom",
)
};
let wrong = issues("RFF+Z19:DE00ANDERE'");
assert_eq!(wrong.len(), 1, "{wrong:?}");
assert_eq!(wrong[0].code(), "CON002");
assert!(issues("RFF+Z19:DE00MELO'").is_empty());
assert!(issues("RFF+Z37:C123'").is_empty());
let gas = |rff: &str| {
check_consistency(
&message(&format!(
"UNH+1+UTILMD:D:11A:UN:G1.2'IDE+24+X'LOC+172+DE00MELO'SEQ+Z03'{rff}UNT+6+1'"
)),
"UTILMD_Gas",
)
};
assert_eq!(gas("RFF+Z19:DE00ANDERE'").len(), 1);
assert!(gas("RFF+Z19:DE00MELO'").is_empty());
assert!(check_consistency(
&message("UNH+1+UTILTS:D:18A:UN:1.1e'IDE+24+X'LOC+Z17+A'SEQ+Z03'RFF+Z19:B'UNT+6+1'"),
"UTILTS"
)
.is_empty());
}
}