Admin 554fde4447
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
fix: scope pallet diff parsing to card region
2026-09-25 21:08:24 +00:00

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, &current.origin),
("variety", &previous.variety, &current.variety),
("brand", &previous.brand, &current.brand),
("class", &previous.class, &current.class),
("base", &previous.base, &current.base),
("pack", &previous.pack, &current.pack),
("grower", &previous.grower, &current.grower),
("packrun", &previous.packrun, &current.packrun),
("size", &previous.size, &current.size),
("qty", &previous.qty, &current.qty),
("pickDate", &previous.pick_date, &current.pick_date),
("packDate", &previous.pack_date, &current.pack_date),
("total", &previous.total, &current.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, &current);
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, &current), 0);
assert!(diff_snapshots(&previous, &current).is_empty());
}
}