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
211 lines
7.4 KiB
Rust
211 lines
7.4 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()
|
|
}
|
|
|
|
pub fn parse_pallet_snapshot(raw_text: &str, pallet_id: &str) -> PalletSnapshot {
|
|
let flat = raw_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);
|
|
|
|
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.variety.is_none() {
|
|
if let Ok(regex) = Regex::new(r"(?i)^\s*Avocados?\s*-\s*([A-Z][A-Z -]{1,20})\s*$") {
|
|
snapshot.variety = raw_text.lines().find_map(|line| {
|
|
regex
|
|
.captures(line)
|
|
.and_then(|captures| captures.get(1))
|
|
.and_then(|value| clean(value.as_str()))
|
|
});
|
|
}
|
|
}
|
|
|
|
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
|
|
"#;
|
|
|
|
#[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 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());
|
|
}
|
|
}
|