Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled
413 lines
15 KiB
Rust
413 lines
15 KiB
Rust
use regex::Regex;
|
|
|
|
use crate::model::{PalletFieldChange, PalletSnapshot};
|
|
|
|
fn capture(text: &str, pattern: &str, group: usize) -> Option<String> {
|
|
let regex = Regex::new(pattern).ok()?;
|
|
let value = regex.captures(text)?.get(group)?.as_str();
|
|
clean(value)
|
|
}
|
|
|
|
fn clean(value: &str) -> Option<String> {
|
|
let collapsed = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
let trimmed = collapsed.trim().trim_matches(|ch: char| matches!(ch, ',' | ';')).trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.trim_end_matches('.').to_string())
|
|
}
|
|
}
|
|
|
|
fn canonical(value: &str) -> String {
|
|
value
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
.trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':'))
|
|
.to_ascii_uppercase()
|
|
}
|
|
|
|
fn find_ci(line: &str, needle: &str) -> Option<usize> {
|
|
line.to_ascii_uppercase().find(&needle.to_ascii_uppercase())
|
|
}
|
|
|
|
fn column_slice(line: &str, start: usize, end: Option<usize>) -> &str {
|
|
let start = start.min(line.len());
|
|
let end = end.unwrap_or(line.len()).min(line.len());
|
|
if start >= end {
|
|
""
|
|
} else {
|
|
line.get(start..end).unwrap_or("")
|
|
}
|
|
}
|
|
|
|
|
|
fn scope_card_region(raw_text: &str) -> String {
|
|
let lines: Vec<&str> = raw_text.lines().collect();
|
|
|
|
let header_index = lines.iter().position(|line| {
|
|
find_ci(line, "ORIGIN").is_some()
|
|
&& find_ci(line, "VAR").is_some()
|
|
&& find_ci(line, "BRAND").is_some()
|
|
&& find_ci(line, "CLASS").is_some()
|
|
});
|
|
|
|
let title_index = lines.iter().position(|line| {
|
|
find_ci(line, "Avocados").is_some() && line.contains('-')
|
|
});
|
|
|
|
let start = match (title_index, header_index) {
|
|
(Some(title), Some(header)) if title <= header && header.saturating_sub(title) <= 4 => title,
|
|
(_, Some(header)) => header,
|
|
(Some(title), None) => title,
|
|
(None, None) => return raw_text.to_string(),
|
|
};
|
|
|
|
let end = lines
|
|
.iter()
|
|
.enumerate()
|
|
.skip(start)
|
|
.find(|(_, line)| find_ci(line, "Total").is_some())
|
|
.map(|(index, _)| index)
|
|
.unwrap_or_else(|| (start + 40).min(lines.len().saturating_sub(1)));
|
|
|
|
lines[start..=end].join("\n")
|
|
}
|
|
|
|
fn parse_layout_tables(raw_text: &str, snapshot: &mut PalletSnapshot) {
|
|
let lines: Vec<&str> = raw_text.lines().collect();
|
|
let right_col = lines
|
|
.iter()
|
|
.filter_map(|line| find_ci(line, "Pallet:").or_else(|| find_ci(line, "Grower")))
|
|
.min();
|
|
|
|
if let Some((index, header)) = lines.iter().enumerate().find(|(_, line)| {
|
|
find_ci(line, "ORIGIN").is_some()
|
|
&& find_ci(line, "VAR").is_some()
|
|
&& find_ci(line, "BRAND").is_some()
|
|
&& find_ci(line, "CLASS").is_some()
|
|
}) {
|
|
let start = find_ci(header, "ORIGIN").unwrap_or(0);
|
|
for line in lines.iter().skip(index + 1).take(4) {
|
|
let tokens: Vec<&str> = column_slice(line, start, right_col)
|
|
.split_whitespace()
|
|
.collect();
|
|
if tokens.len() >= 5
|
|
&& (2..=3).contains(&tokens[0].len())
|
|
&& tokens[0].chars().all(|ch| ch.is_ascii_alphabetic())
|
|
&& tokens[3].eq_ignore_ascii_case("CLASS")
|
|
{
|
|
snapshot.origin = clean(tokens[0]);
|
|
snapshot.variety = clean(tokens[1]);
|
|
snapshot.brand = clean(tokens[2]);
|
|
snapshot.class = clean(&format!("{} {}", tokens[3], tokens[4]));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some((index, header)) = lines.iter().enumerate().find(|(_, line)| {
|
|
find_ci(line, "BASE").is_some() && find_ci(line, "PACK").is_some()
|
|
}) {
|
|
let start = find_ci(header, "BASE").unwrap_or(0);
|
|
for line in lines.iter().skip(index + 1).take(4) {
|
|
let tokens: Vec<&str> = column_slice(line, start, right_col)
|
|
.split_whitespace()
|
|
.collect();
|
|
if tokens.len() >= 2
|
|
&& tokens[0].len() >= 2
|
|
&& tokens[0].chars().all(|ch| ch.is_ascii_alphabetic())
|
|
&& tokens[1].chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
|
|
{
|
|
snapshot.base = clean(tokens[0]);
|
|
snapshot.pack = clean(tokens[1]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some((index, header)) = lines.iter().enumerate().find(|(_, line)| {
|
|
find_ci(line, "Grower").is_some()
|
|
&& find_ci(line, "Packrun").is_some()
|
|
&& find_ci(line, "Size").is_some()
|
|
&& find_ci(line, "Qty").is_some()
|
|
}) {
|
|
let start = find_ci(header, "Grower").unwrap_or_else(|| right_col.unwrap_or(0));
|
|
for line in lines.iter().skip(index + 1).take(5) {
|
|
let tokens: Vec<&str> = column_slice(line, start, None)
|
|
.split_whitespace()
|
|
.collect();
|
|
if tokens.len() >= 4
|
|
&& tokens[0].chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
|
|
&& tokens[1].contains('/')
|
|
&& tokens[2].chars().all(|ch| ch.is_ascii_digit())
|
|
&& tokens[3].chars().all(|ch| ch.is_ascii_digit())
|
|
{
|
|
snapshot.grower = clean(tokens[0]);
|
|
snapshot.packrun = clean(tokens[1]);
|
|
snapshot.size = clean(tokens[2]);
|
|
snapshot.qty = clean(tokens[3]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn parse_pallet_snapshot(raw_text: &str, pallet_id: &str) -> PalletSnapshot {
|
|
let card_text = scope_card_region(raw_text);
|
|
let flat = card_text.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
let mut snapshot = PalletSnapshot {
|
|
pallet_id: pallet_id.trim().to_string(),
|
|
..PalletSnapshot::default()
|
|
};
|
|
|
|
snapshot.pick_date = capture(
|
|
&flat,
|
|
r"(?i)\bPick\s+Date\s*:\s*([0-9]{1,2}[/-][0-9]{1,2}[/-][0-9]{2,4})",
|
|
1,
|
|
);
|
|
snapshot.pack_date = capture(
|
|
&flat,
|
|
r"(?i)\bPack\s+Date\s*:\s*([0-9]{1,2}[/-][0-9]{1,2}[/-][0-9]{2,4})",
|
|
1,
|
|
);
|
|
snapshot.printed_time = capture(
|
|
&flat,
|
|
r"(?i)\bTime\s*:\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)",
|
|
1,
|
|
);
|
|
snapshot.total = capture(&flat, r"(?i)\bTotal\s+([0-9]{1,5})\b", 1);
|
|
|
|
parse_layout_tables(&card_text, &mut snapshot);
|
|
|
|
if snapshot.grower.is_none() {
|
|
if let Ok(regex) = Regex::new(
|
|
r"(?i)\bGrower\s+Packrun\s+Size\s+Qty\s+([A-Z0-9-]+)\s+([A-Z0-9/._-]+)\s+([0-9]{1,3})\s+([0-9]{1,5})\b",
|
|
) {
|
|
if let Some(captures) = regex.captures(&flat) {
|
|
snapshot.grower = captures.get(1).and_then(|m| clean(m.as_str()));
|
|
snapshot.packrun = captures.get(2).and_then(|m| clean(m.as_str()));
|
|
snapshot.size = captures.get(3).and_then(|m| clean(m.as_str()));
|
|
snapshot.qty = captures.get(4).and_then(|m| clean(m.as_str()));
|
|
}
|
|
}
|
|
}
|
|
|
|
if snapshot.origin.is_none() {
|
|
if let Ok(regex) = Regex::new(
|
|
r"(?i)\bORIGIN\s+VAR\s+BRAND\s+CLASS(?:\s+Pallet\s*:\s*[A-Z0-9-]+)?\s+([A-Z]{2,3})\s+([A-Z][A-Z -]{1,20}?)\s+([A-Z0-9]{2,12})\s+(CLASS\s*\.?\s*[0-9]+\.?)(?:\s|$)",
|
|
) {
|
|
if let Some(captures) = regex.captures(&flat) {
|
|
snapshot.origin = captures.get(1).and_then(|m| clean(m.as_str()));
|
|
snapshot.variety = captures.get(2).and_then(|m| clean(m.as_str()));
|
|
snapshot.brand = captures.get(3).and_then(|m| clean(m.as_str()));
|
|
snapshot.class = captures.get(4).and_then(|m| clean(m.as_str()));
|
|
}
|
|
}
|
|
}
|
|
|
|
if snapshot.variety.is_none() {
|
|
if let Ok(regex) = Regex::new(r"(?i)^\s*Avocados?\s*-\s*([A-Z][A-Z -]{1,20})\s*$") {
|
|
snapshot.variety = card_text.lines().find_map(|line| {
|
|
regex
|
|
.captures(line)
|
|
.and_then(|captures| captures.get(1))
|
|
.and_then(|value| clean(value.as_str()))
|
|
});
|
|
}
|
|
}
|
|
|
|
if snapshot.base.is_none() {
|
|
if let Ok(regex) = Regex::new(
|
|
r"(?i)\bBASE\s+PACK\s+([A-Z]{2,12})\s+([A-Z0-9-]{2,12})\b",
|
|
) {
|
|
if let Some(captures) = regex.captures(&flat) {
|
|
snapshot.base = captures.get(1).and_then(|m| clean(m.as_str()));
|
|
snapshot.pack = captures.get(2).and_then(|m| clean(m.as_str()));
|
|
}
|
|
}
|
|
}
|
|
|
|
snapshot
|
|
}
|
|
|
|
fn comparable_pairs<'a>(
|
|
previous: &'a PalletSnapshot,
|
|
current: &'a PalletSnapshot,
|
|
) -> [(&'static str, &'a Option<String>, &'a Option<String>); 13] {
|
|
[
|
|
("origin", &previous.origin, ¤t.origin),
|
|
("variety", &previous.variety, ¤t.variety),
|
|
("brand", &previous.brand, ¤t.brand),
|
|
("class", &previous.class, ¤t.class),
|
|
("base", &previous.base, ¤t.base),
|
|
("pack", &previous.pack, ¤t.pack),
|
|
("grower", &previous.grower, ¤t.grower),
|
|
("packrun", &previous.packrun, ¤t.packrun),
|
|
("size", &previous.size, ¤t.size),
|
|
("qty", &previous.qty, ¤t.qty),
|
|
("pickDate", &previous.pick_date, ¤t.pick_date),
|
|
("packDate", &previous.pack_date, ¤t.pack_date),
|
|
("total", &previous.total, ¤t.total),
|
|
]
|
|
}
|
|
|
|
pub fn comparable_field_count(previous: &PalletSnapshot, current: &PalletSnapshot) -> usize {
|
|
comparable_pairs(previous, current)
|
|
.into_iter()
|
|
.filter(|(_, before, after)| before.is_some() && after.is_some())
|
|
.count()
|
|
}
|
|
|
|
pub fn diff_snapshots(
|
|
previous: &PalletSnapshot,
|
|
current: &PalletSnapshot,
|
|
) -> Vec<PalletFieldChange> {
|
|
comparable_pairs(previous, current)
|
|
.into_iter()
|
|
.filter_map(|(field, before, after)| {
|
|
let before = before.as_ref()?;
|
|
let after = after.as_ref()?;
|
|
if canonical(before) == canonical(after) {
|
|
return None;
|
|
}
|
|
Some(PalletFieldChange {
|
|
field: field.to_string(),
|
|
previous: before.clone(),
|
|
current: after.clone(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const CARD: &str = r#"
|
|
Avocados - Hass
|
|
ORIGIN VAR BRAND CLASS
|
|
NZ Hass NZAG CLASS 3.
|
|
BASE PACK
|
|
CHEP B37
|
|
Pick Date: 16/09/2026
|
|
Pack Date: 17/09/2026
|
|
Time: 14:46:10
|
|
Pallet: 40595983
|
|
Grower Packrun Size Qty
|
|
89930 89930/16/17/26 20 30
|
|
Total 30
|
|
"#;
|
|
|
|
const LAYOUT_CARD: &str = r#"
|
|
Avocados - Hass
|
|
ORIGIN VAR BRAND CLASS Pallet: 40595983
|
|
NZ Hass NZAG CLASS 3. Grower Packrun Size Qty
|
|
BASE PACK 89930 89930/16/17/26 20 30
|
|
CHEP B37
|
|
Pick Date: 16/09/2026
|
|
Pack Date: 17/09/2026
|
|
Time: 14:46:10
|
|
Total 30
|
|
"#;
|
|
|
|
const WHOLE_SHEET: &str = r#"
|
|
20 JA 40595983
|
|
JA 40595983 JA 40595983 JA 40595983
|
|
20
|
|
Avocados - Hass
|
|
ORIGIN VAR BRAND CLASS Pallet: 40595983
|
|
NZ Hass NZAG CLASS 3. Grower Packrun Size Qty
|
|
BASE PACK 89930 89930/16/17/26 20 30
|
|
CHEP B37
|
|
Pick Date: 16/09/2026
|
|
Pack Date: 17/09/2026
|
|
Time: 14:46:10
|
|
This product meets export quality specifications
|
|
Signature:
|
|
Total 30
|
|
JA 40595983 JA 40595983 JA 40595983
|
|
20 20 20 20
|
|
"#;
|
|
|
|
#[test]
|
|
fn scopes_the_two_column_card_inside_the_full_print_sheet() {
|
|
let scoped = scope_card_region(WHOLE_SHEET);
|
|
assert!(scoped.contains("ORIGIN"));
|
|
assert!(scoped.contains("Grower"));
|
|
assert!(scoped.contains("Total"));
|
|
assert!(!scoped.contains("20 JA 40595983"));
|
|
assert!(!scoped.contains("20 20 20 20"));
|
|
|
|
let snapshot = parse_pallet_snapshot(WHOLE_SHEET, "40595983");
|
|
assert_eq!(snapshot.origin.as_deref(), Some("NZ"));
|
|
assert_eq!(snapshot.variety.as_deref(), Some("Hass"));
|
|
assert_eq!(snapshot.brand.as_deref(), Some("NZAG"));
|
|
assert_eq!(snapshot.class.as_deref(), Some("CLASS 3"));
|
|
assert_eq!(snapshot.base.as_deref(), Some("CHEP"));
|
|
assert_eq!(snapshot.pack.as_deref(), Some("B37"));
|
|
assert_eq!(snapshot.grower.as_deref(), Some("89930"));
|
|
assert_eq!(snapshot.packrun.as_deref(), Some("89930/16/17/26"));
|
|
assert_eq!(snapshot.size.as_deref(), Some("20"));
|
|
assert_eq!(snapshot.qty.as_deref(), Some("30"));
|
|
assert_eq!(snapshot.total.as_deref(), Some("30"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_export_card_fields() {
|
|
let snapshot = parse_pallet_snapshot(CARD, "40595983");
|
|
assert_eq!(snapshot.pallet_id, "40595983");
|
|
assert_eq!(snapshot.origin.as_deref(), Some("NZ"));
|
|
assert_eq!(snapshot.variety.as_deref(), Some("Hass"));
|
|
assert_eq!(snapshot.brand.as_deref(), Some("NZAG"));
|
|
assert_eq!(snapshot.class.as_deref(), Some("CLASS 3"));
|
|
assert_eq!(snapshot.base.as_deref(), Some("CHEP"));
|
|
assert_eq!(snapshot.pack.as_deref(), Some("B37"));
|
|
assert_eq!(snapshot.grower.as_deref(), Some("89930"));
|
|
assert_eq!(snapshot.packrun.as_deref(), Some("89930/16/17/26"));
|
|
assert_eq!(snapshot.size.as_deref(), Some("20"));
|
|
assert_eq!(snapshot.qty.as_deref(), Some("30"));
|
|
assert_eq!(snapshot.total.as_deref(), Some("30"));
|
|
assert_eq!(snapshot.printed_time.as_deref(), Some("14:46:10"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_interleaved_pdf_layout_columns() {
|
|
let snapshot = parse_pallet_snapshot(LAYOUT_CARD, "40595983");
|
|
assert_eq!(snapshot.origin.as_deref(), Some("NZ"));
|
|
assert_eq!(snapshot.brand.as_deref(), Some("NZAG"));
|
|
assert_eq!(snapshot.base.as_deref(), Some("CHEP"));
|
|
assert_eq!(snapshot.pack.as_deref(), Some("B37"));
|
|
assert_eq!(snapshot.grower.as_deref(), Some("89930"));
|
|
assert_eq!(snapshot.packrun.as_deref(), Some("89930/16/17/26"));
|
|
assert_eq!(snapshot.size.as_deref(), Some("20"));
|
|
assert_eq!(snapshot.qty.as_deref(), Some("30"));
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_print_time_but_reports_business_field_changes() {
|
|
let previous = parse_pallet_snapshot(CARD, "40595983");
|
|
let changed = CARD
|
|
.replace("Time: 14:46:10", "Time: 15:07:42")
|
|
.replace("20 30\n Total 30", "20 28\n Total 28");
|
|
let current = parse_pallet_snapshot(&changed, "40595983");
|
|
let diff = diff_snapshots(&previous, ¤t);
|
|
|
|
assert_eq!(diff.len(), 2);
|
|
assert!(diff.iter().any(|item| item.field == "qty" && item.current == "28"));
|
|
assert!(diff.iter().any(|item| item.field == "total" && item.current == "28"));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_extraction_is_not_treated_as_a_changed_value() {
|
|
let previous = parse_pallet_snapshot(CARD, "40595983");
|
|
let current = PalletSnapshot {
|
|
pallet_id: "40595983".into(),
|
|
..PalletSnapshot::default()
|
|
};
|
|
|
|
assert_eq!(comparable_field_count(&previous, ¤t), 0);
|
|
assert!(diff_snapshots(&previous, ¤t).is_empty());
|
|
}
|
|
}
|