use regex::Regex; use crate::model::{PalletFieldChange, PalletSnapshot}; fn capture(text: &str, pattern: &str, group: usize) -> Option { let regex = Regex::new(pattern).ok()?; let value = regex.captures(text)?.get(group)?.as_str(); clean(value) } fn clean(value: &str) -> Option { let collapsed = value.split_whitespace().collect::>().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::>() .join(" ") .trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':')) .to_ascii_uppercase() } fn find_ci(line: &str, needle: &str) -> Option { line.to_ascii_uppercase().find(&needle.to_ascii_uppercase()) } fn column_slice(line: &str, start: usize, end: Option) -> &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::>().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, &'a Option); 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 { 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()); } }