Admin 288ad0b3a5
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: complete pallet reprint tracking build
2026-09-25 21:02:25 +00:00

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, &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
"#;
#[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, &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());
}
}