diff --git a/src-tauri/src/pallet.rs b/src-tauri/src/pallet.rs index d119182..5ae1f1a 100644 --- a/src-tauri/src/pallet.rs +++ b/src-tauri/src/pallet.rs @@ -27,6 +27,99 @@ fn canonical(value: &str) -> String { .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 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 flat = raw_text.split_whitespace().collect::>().join(" "); let mut snapshot = PalletSnapshot { @@ -51,25 +144,31 @@ pub fn parse_pallet_snapshot(raw_text: &str, pallet_id: &str) -> PalletSnapshot ); snapshot.total = capture(&flat, r"(?i)\bTotal\s+([0-9]{1,5})\b", 1); - 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())); + parse_layout_tables(raw_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 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.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())); + } } } @@ -84,12 +183,14 @@ pub fn parse_pallet_snapshot(raw_text: &str, pallet_id: &str) -> PalletSnapshot } } - 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())); + 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())); + } } } @@ -164,6 +265,18 @@ mod tests { 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 + "#; + #[test] fn parses_export_card_fields() { let snapshot = parse_pallet_snapshot(CARD, "40595983"); @@ -182,6 +295,19 @@ mod tests { 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");