feat: track pallet reprints and field changes
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
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
This commit is contained in:
parent
218a01180a
commit
4f2feeb1cc
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@fieldlens/print",
|
"name": "@fieldlens/print",
|
||||||
"version": "0.1.19",
|
"version": "0.1.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "fieldlens-print"
|
name = "fieldlens-print"
|
||||||
version = "0.1.19"
|
version = "0.1.20"
|
||||||
description = "FieldLens print-stream barcode capture companion"
|
description = "FieldLens print-stream barcode capture companion"
|
||||||
authors = ["Arrowstack"]
|
authors = ["Arrowstack"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@ -20,6 +20,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{AppConfig, CaptureMode, JobSummary},
|
model::{AppConfig, CaptureMode, JobSummary},
|
||||||
|
pallet::parse_pallet_snapshot,
|
||||||
state::SharedState,
|
state::SharedState,
|
||||||
zpl::extract_barcodes,
|
zpl::extract_barcodes,
|
||||||
};
|
};
|
||||||
@ -178,6 +179,7 @@ fn raw_proxy_loop(
|
|||||||
label: "listener".into(),
|
label: "listener".into(),
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
barcodes: vec![],
|
barcodes: vec![],
|
||||||
|
pallet_print: None,
|
||||||
captured_at: chrono::Utc::now().to_rfc3339(),
|
captured_at: chrono::Utc::now().to_rfc3339(),
|
||||||
forwarded: false,
|
forwarded: false,
|
||||||
error: Some(format!("accept failed: {error}")),
|
error: Some(format!("accept failed: {error}")),
|
||||||
@ -437,6 +439,7 @@ fn process_captured_pdf(
|
|||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
bytes,
|
bytes,
|
||||||
barcodes: vec![],
|
barcodes: vec![],
|
||||||
|
pallet_print: None,
|
||||||
captured_at: chrono::Utc::now().to_rfc3339(),
|
captured_at: chrono::Utc::now().to_rfc3339(),
|
||||||
forwarded,
|
forwarded,
|
||||||
error: Some(format!("archive captured PDF failed: {error}")),
|
error: Some(format!("archive captured PDF failed: {error}")),
|
||||||
@ -451,6 +454,7 @@ fn process_captured_pdf(
|
|||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
bytes,
|
bytes,
|
||||||
barcodes: vec![],
|
barcodes: vec![],
|
||||||
|
pallet_print: None,
|
||||||
captured_at: chrono::Utc::now().to_rfc3339(),
|
captured_at: chrono::Utc::now().to_rfc3339(),
|
||||||
forwarded,
|
forwarded,
|
||||||
error: None,
|
error: None,
|
||||||
@ -483,22 +487,38 @@ fn process_captured_pdf(
|
|||||||
|
|
||||||
match decode_pdf_page(&target, page, &id) {
|
match decode_pdf_page(&target, page, &id) {
|
||||||
Ok(found) => {
|
Ok(found) => {
|
||||||
|
let primary = found.first().cloned();
|
||||||
|
|
||||||
for barcode in found {
|
for barcode in found {
|
||||||
if job.barcodes.iter().any(|existing|
|
if job.barcodes.iter().any(|existing|
|
||||||
existing.format == barcode.format && existing.value == barcode.value
|
existing.format == barcode.format && existing.value == barcode.value
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.queue_scan(
|
|
||||||
barcode.value.clone(),
|
|
||||||
barcode.format.clone(),
|
|
||||||
"print".into(),
|
|
||||||
&id,
|
|
||||||
label,
|
|
||||||
);
|
|
||||||
job.barcodes.push(barcode);
|
job.barcodes.push(barcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if job.pallet_print.is_none() {
|
||||||
|
if let Some(barcode) = primary.or_else(|| job.barcodes.first().cloned()) {
|
||||||
|
let text = extract_pdf_page_text(&target, page).unwrap_or_default();
|
||||||
|
let snapshot = parse_pallet_snapshot(&text, &barcode.value);
|
||||||
|
let comparison = state.record_print_event(
|
||||||
|
format!("{source}:{label}"),
|
||||||
|
&id,
|
||||||
|
&job.captured_at,
|
||||||
|
snapshot,
|
||||||
|
);
|
||||||
|
state.queue_print_scan(
|
||||||
|
barcode.value,
|
||||||
|
barcode.format,
|
||||||
|
&id,
|
||||||
|
label,
|
||||||
|
&comparison,
|
||||||
|
);
|
||||||
|
job.pallet_print = Some(comparison);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Upsert the same job after each page so barcode results can
|
// Upsert the same job after each page so barcode results can
|
||||||
// appear incrementally without creating duplicate jobs.
|
// appear incrementally without creating duplicate jobs.
|
||||||
state.push_job(app, job.clone());
|
state.push_job(app, job.clone());
|
||||||
@ -553,6 +573,37 @@ fn pdf_page_count(path: &std::path::Path) -> Result<u32, String> {
|
|||||||
Ok(pages)
|
Ok(pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn extract_pdf_page_text(path: &std::path::Path, page: u32) -> Result<String, String> {
|
||||||
|
let output = Command::new("pdftotext")
|
||||||
|
.args([
|
||||||
|
"-f",
|
||||||
|
&page.to_string(),
|
||||||
|
"-l",
|
||||||
|
&page.to_string(),
|
||||||
|
"-layout",
|
||||||
|
])
|
||||||
|
.arg(path)
|
||||||
|
.arg("-")
|
||||||
|
.output()
|
||||||
|
.map_err(|error| {
|
||||||
|
if error.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
"PDF text extraction requires poppler-utils (pdftotext)".to_string()
|
||||||
|
} else {
|
||||||
|
format!("run pdftotext: {error}")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"pdftotext failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn decode_pdf_page(
|
fn decode_pdf_page(
|
||||||
path: &std::path::Path,
|
path: &std::path::Path,
|
||||||
@ -775,16 +826,25 @@ fn process_payload(
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
let error = error.or(parse_error);
|
let error = error.or(parse_error);
|
||||||
|
let captured_at = chrono::Utc::now().to_rfc3339();
|
||||||
for barcode in &barcodes {
|
let pallet_print = barcodes.first().map(|barcode| {
|
||||||
state.queue_scan(
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
let snapshot = parse_pallet_snapshot(&text, &barcode.value);
|
||||||
|
let comparison = state.record_print_event(
|
||||||
|
format!("{source}:{id}"),
|
||||||
|
&id,
|
||||||
|
&captured_at,
|
||||||
|
snapshot,
|
||||||
|
);
|
||||||
|
state.queue_print_scan(
|
||||||
barcode.value.clone(),
|
barcode.value.clone(),
|
||||||
barcode.format.clone(),
|
barcode.format.clone(),
|
||||||
"print".into(),
|
|
||||||
&id,
|
&id,
|
||||||
&label,
|
&label,
|
||||||
|
&comparison,
|
||||||
);
|
);
|
||||||
}
|
comparison
|
||||||
|
});
|
||||||
|
|
||||||
state.push_job(
|
state.push_job(
|
||||||
app,
|
app,
|
||||||
@ -794,7 +854,8 @@ fn process_payload(
|
|||||||
label,
|
label,
|
||||||
bytes: bytes.len(),
|
bytes: bytes.len(),
|
||||||
barcodes,
|
barcodes,
|
||||||
captured_at: chrono::Utc::now().to_rfc3339(),
|
pallet_print,
|
||||||
|
captured_at,
|
||||||
forwarded,
|
forwarded,
|
||||||
error,
|
error,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
mod capture;
|
mod capture;
|
||||||
mod fieldlens;
|
mod fieldlens;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod pallet;
|
||||||
mod printers;
|
mod printers;
|
||||||
mod state;
|
mod state;
|
||||||
mod updates;
|
mod updates;
|
||||||
|
|||||||
@ -58,6 +58,63 @@ pub struct DecodedBarcode {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PalletSnapshot {
|
||||||
|
pub pallet_id: String,
|
||||||
|
pub origin: Option<String>,
|
||||||
|
pub variety: Option<String>,
|
||||||
|
pub brand: Option<String>,
|
||||||
|
pub class: Option<String>,
|
||||||
|
pub base: Option<String>,
|
||||||
|
pub pack: Option<String>,
|
||||||
|
pub grower: Option<String>,
|
||||||
|
pub packrun: Option<String>,
|
||||||
|
pub size: Option<String>,
|
||||||
|
pub qty: Option<String>,
|
||||||
|
pub pick_date: Option<String>,
|
||||||
|
pub pack_date: Option<String>,
|
||||||
|
pub total: Option<String>,
|
||||||
|
pub printed_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PalletFieldChange {
|
||||||
|
pub field: String,
|
||||||
|
pub previous: String,
|
||||||
|
pub current: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum PalletPrintStatus {
|
||||||
|
NewPallet,
|
||||||
|
ReprintSame,
|
||||||
|
ReprintChanged,
|
||||||
|
ReprintUnreadable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PalletPrintComparison {
|
||||||
|
pub pallet_id: String,
|
||||||
|
pub status: PalletPrintStatus,
|
||||||
|
pub occurrence: usize,
|
||||||
|
pub previous_captured_at: Option<String>,
|
||||||
|
pub changes: Vec<PalletFieldChange>,
|
||||||
|
pub snapshot: PalletSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PalletPrintRecord {
|
||||||
|
pub source_key: String,
|
||||||
|
pub job_id: String,
|
||||||
|
pub captured_at: String,
|
||||||
|
pub comparison: PalletPrintComparison,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct JobSummary {
|
pub struct JobSummary {
|
||||||
@ -66,6 +123,7 @@ pub struct JobSummary {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub bytes: usize,
|
pub bytes: usize,
|
||||||
pub barcodes: Vec<DecodedBarcode>,
|
pub barcodes: Vec<DecodedBarcode>,
|
||||||
|
pub pallet_print: Option<PalletPrintComparison>,
|
||||||
pub captured_at: String,
|
pub captured_at: String,
|
||||||
pub forwarded: bool,
|
pub forwarded: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
|||||||
207
src-tauri/src/pallet.rs
Normal file
207
src-tauri/src/pallet.rs
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
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() {
|
||||||
|
snapshot.variety = capture(
|
||||||
|
&flat,
|
||||||
|
r"(?i)\bAvocados?\s*-\s*([A-Z][A-Z -]{1,20}?)(?=\s+(?:ORIGIN|Pallet|Grower|Pick|Pack|Time|Total)\b|$)",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,15 +11,23 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
use crate::model::{AppConfig, AuthState, JobSummary, ScanEvent};
|
use crate::{
|
||||||
|
model::{
|
||||||
|
AppConfig, AuthState, JobSummary, PalletPrintComparison, PalletPrintRecord,
|
||||||
|
PalletPrintStatus, PalletSnapshot, ScanEvent,
|
||||||
|
},
|
||||||
|
pallet::{comparable_field_count, diff_snapshots},
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_JOBS: usize = 200;
|
const MAX_JOBS: usize = 200;
|
||||||
|
const MAX_PRINT_HISTORY: usize = 5000;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SharedState {
|
pub struct SharedState {
|
||||||
pub config: Arc<Mutex<AppConfig>>,
|
pub config: Arc<Mutex<AppConfig>>,
|
||||||
pub jobs: Arc<Mutex<VecDeque<JobSummary>>>,
|
pub jobs: Arc<Mutex<VecDeque<JobSummary>>>,
|
||||||
pub pending: Arc<Mutex<Vec<ScanEvent>>>,
|
pub pending: Arc<Mutex<Vec<ScanEvent>>>,
|
||||||
|
pub print_history: Arc<Mutex<Vec<PalletPrintRecord>>>,
|
||||||
pub auth: Arc<Mutex<AuthState>>,
|
pub auth: Arc<Mutex<AuthState>>,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
}
|
}
|
||||||
@ -38,6 +46,9 @@ impl SharedState {
|
|||||||
fs::create_dir_all(&config.spool_dir).ok();
|
fs::create_dir_all(&config.spool_dir).ok();
|
||||||
|
|
||||||
let pending = read_json::<Vec<ScanEvent>>(&data_dir.join("pending.json")).unwrap_or_default();
|
let pending = read_json::<Vec<ScanEvent>>(&data_dir.join("pending.json")).unwrap_or_default();
|
||||||
|
let print_history =
|
||||||
|
read_json::<Vec<PalletPrintRecord>>(&data_dir.join("print-history.json"))
|
||||||
|
.unwrap_or_default();
|
||||||
let persisted_auth =
|
let persisted_auth =
|
||||||
read_json::<PersistedAuth>(&data_dir.join("auth.json")).unwrap_or_default();
|
read_json::<PersistedAuth>(&data_dir.join("auth.json")).unwrap_or_default();
|
||||||
let auth = AuthState {
|
let auth = AuthState {
|
||||||
@ -49,6 +60,7 @@ impl SharedState {
|
|||||||
config: Arc::new(Mutex::new(config)),
|
config: Arc::new(Mutex::new(config)),
|
||||||
jobs: Arc::new(Mutex::new(VecDeque::new())),
|
jobs: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
pending: Arc::new(Mutex::new(pending)),
|
pending: Arc::new(Mutex::new(pending)),
|
||||||
|
print_history: Arc::new(Mutex::new(print_history)),
|
||||||
auth: Arc::new(Mutex::new(auth)),
|
auth: Arc::new(Mutex::new(auth)),
|
||||||
data_dir,
|
data_dir,
|
||||||
};
|
};
|
||||||
@ -64,6 +76,13 @@ impl SharedState {
|
|||||||
write_json_atomic(&self.data_dir.join("pending.json"), &*self.pending.lock())
|
write_json_atomic(&self.data_dir.join("pending.json"), &*self.pending.lock())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn persist_print_history(&self) -> Result<()> {
|
||||||
|
write_json_atomic(
|
||||||
|
&self.data_dir.join("print-history.json"),
|
||||||
|
&*self.print_history.lock(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn persist_auth(&self) -> Result<()> {
|
pub fn persist_auth(&self) -> Result<()> {
|
||||||
let path = self.data_dir.join("auth.json");
|
let path = self.data_dir.join("auth.json");
|
||||||
let persisted = PersistedAuth {
|
let persisted = PersistedAuth {
|
||||||
@ -107,30 +126,89 @@ impl SharedState {
|
|||||||
let _ = app.emit("capture-job", &job);
|
let _ = app.emit("capture-job", &job);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn queue_scan(
|
pub fn record_print_event(
|
||||||
|
&self,
|
||||||
|
source_key: String,
|
||||||
|
job_id: &str,
|
||||||
|
captured_at: &str,
|
||||||
|
snapshot: PalletSnapshot,
|
||||||
|
) -> PalletPrintComparison {
|
||||||
|
let mut history = self.print_history.lock();
|
||||||
|
|
||||||
|
if let Some(existing) = history.iter().find(|record| record.source_key == source_key) {
|
||||||
|
return existing.comparison.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous = history
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|record| record.comparison.pallet_id == snapshot.pallet_id);
|
||||||
|
let occurrence = history
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.comparison.pallet_id == snapshot.pallet_id)
|
||||||
|
.count()
|
||||||
|
+ 1;
|
||||||
|
|
||||||
|
let (status, previous_captured_at, changes) = if let Some(previous) = previous {
|
||||||
|
let changes = diff_snapshots(&previous.comparison.snapshot, &snapshot);
|
||||||
|
let comparable = comparable_field_count(&previous.comparison.snapshot, &snapshot);
|
||||||
|
let status = if !changes.is_empty() {
|
||||||
|
PalletPrintStatus::ReprintChanged
|
||||||
|
} else if comparable > 0 {
|
||||||
|
PalletPrintStatus::ReprintSame
|
||||||
|
} else {
|
||||||
|
PalletPrintStatus::ReprintUnreadable
|
||||||
|
};
|
||||||
|
(status, Some(previous.captured_at.clone()), changes)
|
||||||
|
} else {
|
||||||
|
(PalletPrintStatus::NewPallet, None, Vec::new())
|
||||||
|
};
|
||||||
|
|
||||||
|
let comparison = PalletPrintComparison {
|
||||||
|
pallet_id: snapshot.pallet_id.clone(),
|
||||||
|
status,
|
||||||
|
occurrence,
|
||||||
|
previous_captured_at,
|
||||||
|
changes,
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
history.push(PalletPrintRecord {
|
||||||
|
source_key,
|
||||||
|
job_id: job_id.to_string(),
|
||||||
|
captured_at: captured_at.to_string(),
|
||||||
|
comparison: comparison.clone(),
|
||||||
|
});
|
||||||
|
if history.len() > MAX_PRINT_HISTORY {
|
||||||
|
let overflow = history.len() - MAX_PRINT_HISTORY;
|
||||||
|
history.drain(0..overflow);
|
||||||
|
}
|
||||||
|
drop(history);
|
||||||
|
let _ = self.persist_print_history();
|
||||||
|
comparison
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn queue_print_scan(
|
||||||
&self,
|
&self,
|
||||||
raw_value: String,
|
raw_value: String,
|
||||||
format: String,
|
format: String,
|
||||||
source: String,
|
|
||||||
job_id: &str,
|
job_id: &str,
|
||||||
label: &str,
|
label: &str,
|
||||||
|
comparison: &PalletPrintComparison,
|
||||||
) -> ScanEvent {
|
) -> ScanEvent {
|
||||||
let mut pending = self.pending.lock();
|
|
||||||
let duplicate = pending.iter().any(|event| event.raw_value == raw_value);
|
|
||||||
let event = ScanEvent {
|
let event = ScanEvent {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
raw_value,
|
raw_value,
|
||||||
format,
|
format,
|
||||||
duplicate,
|
duplicate: !matches!(comparison.status, PalletPrintStatus::NewPallet),
|
||||||
captured_at: chrono::Utc::now().to_rfc3339(),
|
captured_at: chrono::Utc::now().to_rfc3339(),
|
||||||
source,
|
source: "print".into(),
|
||||||
source_metadata: json!({
|
source_metadata: json!({
|
||||||
"jobId": job_id,
|
"jobId": job_id,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"palletPrint": comparison,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
pending.push(event.clone());
|
self.pending.lock().push(event.clone());
|
||||||
drop(pending);
|
|
||||||
let _ = self.persist_pending();
|
let _ = self.persist_pending();
|
||||||
event
|
event
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "FieldLens Print",
|
"productName": "FieldLens Print",
|
||||||
"version": "0.1.19",
|
"version": "0.1.20",
|
||||||
"identifier": "dev.arrowstack.fieldlens.print",
|
"identifier": "dev.arrowstack.fieldlens.print",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
72
src/main.ts
72
src/main.ts
@ -38,12 +38,29 @@ type VirtualPrinterStatus = {
|
|||||||
|
|
||||||
type Barcode = { format: string; value: string }
|
type Barcode = { format: string; value: string }
|
||||||
|
|
||||||
|
type PalletPrintStatus = 'new-pallet' | 'reprint-same' | 'reprint-changed' | 'reprint-unreadable'
|
||||||
|
|
||||||
|
type PalletFieldChange = {
|
||||||
|
field: string
|
||||||
|
previous: string
|
||||||
|
current: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PalletPrintComparison = {
|
||||||
|
palletId: string
|
||||||
|
status: PalletPrintStatus
|
||||||
|
occurrence: number
|
||||||
|
previousCapturedAt?: string | null
|
||||||
|
changes: PalletFieldChange[]
|
||||||
|
}
|
||||||
|
|
||||||
type Job = {
|
type Job = {
|
||||||
id: string
|
id: string
|
||||||
source: string
|
source: string
|
||||||
label: string
|
label: string
|
||||||
bytes: number
|
bytes: number
|
||||||
barcodes: Barcode[]
|
barcodes: Barcode[]
|
||||||
|
palletPrint?: PalletPrintComparison | null
|
||||||
capturedAt: string
|
capturedAt: string
|
||||||
forwarded: boolean
|
forwarded: boolean
|
||||||
error?: string | null
|
error?: string | null
|
||||||
@ -124,7 +141,7 @@ let config: AppConfig
|
|||||||
let jobs: Job[] = []
|
let jobs: Job[] = []
|
||||||
let status: Status = { running: false, mode: 'raw-proxy', pending: 0, jobs: 0 }
|
let status: Status = { running: false, mode: 'raw-proxy', pending: 0, jobs: 0 }
|
||||||
let loginTimer: number | undefined
|
let loginTimer: number | undefined
|
||||||
let currentVersion = '0.1.19'
|
let currentVersion = '0.1.20'
|
||||||
let bundleType: BundleType | null = null
|
let bundleType: BundleType | null = null
|
||||||
let availableUpdate: Update | null = null
|
let availableUpdate: Update | null = null
|
||||||
let availableManualUpdate: ManualUpdate | null = null
|
let availableManualUpdate: ManualUpdate | null = null
|
||||||
@ -563,6 +580,32 @@ function friendlySource(source: string) {
|
|||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldLabel(field: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
origin: 'Origin',
|
||||||
|
variety: 'Variety',
|
||||||
|
brand: 'Brand',
|
||||||
|
class: 'Class',
|
||||||
|
base: 'Base',
|
||||||
|
pack: 'Pack',
|
||||||
|
grower: 'Grower',
|
||||||
|
packrun: 'Packrun',
|
||||||
|
size: 'Size',
|
||||||
|
qty: 'Qty',
|
||||||
|
pickDate: 'Pick date',
|
||||||
|
packDate: 'Pack date',
|
||||||
|
total: 'Total',
|
||||||
|
}
|
||||||
|
return labels[field] ?? field
|
||||||
|
}
|
||||||
|
|
||||||
|
function palletStatusLabel(status: PalletPrintStatus) {
|
||||||
|
if (status === 'new-pallet') return 'First print'
|
||||||
|
if (status === 'reprint-same') return 'Reprint · same'
|
||||||
|
if (status === 'reprint-changed') return 'Reprint · changed'
|
||||||
|
return 'Reprint · diff unavailable'
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const pill = el<HTMLDivElement>('status-pill')
|
const pill = el<HTMLDivElement>('status-pill')
|
||||||
const captureLive = (status?.running ?? false) && !captureError
|
const captureLive = (status?.running ?? false) && !captureError
|
||||||
@ -588,9 +631,28 @@ function render() {
|
|||||||
const isTest = job.source === 'cups-test'
|
const isTest = job.source === 'cups-test'
|
||||||
const routeText = isTest ? 'No paper' : job.forwarded ? 'Printed' : 'Captured'
|
const routeText = isTest ? 'No paper' : job.forwarded ? 'Printed' : 'Captured'
|
||||||
const routeClass = isTest ? 'test' : job.forwarded ? 'success' : 'neutral'
|
const routeClass = isTest ? 'test' : job.forwarded ? 'success' : 'neutral'
|
||||||
const decodeText = job.barcodes.length
|
const decodeText = job.palletPrint
|
||||||
? `${job.barcodes.length} code${job.barcodes.length === 1 ? '' : 's'} detected`
|
? `Pallet ${job.palletPrint.palletId}`
|
||||||
: 'Captured · no barcode decoded yet'
|
: job.barcodes.length
|
||||||
|
? `${job.barcodes.length} code${job.barcodes.length === 1 ? '' : 's'} detected`
|
||||||
|
: 'Captured · no barcode decoded yet'
|
||||||
|
const palletPrint = job.palletPrint
|
||||||
|
const palletBadge = palletPrint
|
||||||
|
? `<span class="pallet-badge ${escapeHtml(palletPrint.status)}">${escapeHtml(palletStatusLabel(palletPrint.status))}</span>`
|
||||||
|
: ''
|
||||||
|
const palletDetail = palletPrint
|
||||||
|
? `
|
||||||
|
<div class="pallet-print-detail">
|
||||||
|
<span>Print #${palletPrint.occurrence}</span>
|
||||||
|
${palletPrint.previousCapturedAt ? `<span>Previous ${new Date(palletPrint.previousCapturedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${palletPrint.changes.length
|
||||||
|
? `<div class="pallet-diff">${palletPrint.changes.map((change) => `
|
||||||
|
<span><strong>${escapeHtml(fieldLabel(change.field))}</strong> ${escapeHtml(change.previous)} → ${escapeHtml(change.current)}</span>
|
||||||
|
`).join('')}</div>`
|
||||||
|
: ''}
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<article class="job">
|
<article class="job">
|
||||||
@ -598,9 +660,11 @@ function render() {
|
|||||||
<div class="job-title-row">
|
<div class="job-title-row">
|
||||||
<span class="source-badge">${escapeHtml(source)}</span>
|
<span class="source-badge">${escapeHtml(source)}</span>
|
||||||
<span class="route-badge ${routeClass}">${routeText}</span>
|
<span class="route-badge ${routeClass}">${routeText}</span>
|
||||||
|
${palletBadge}
|
||||||
<time>${new Date(job.capturedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time>
|
<time>${new Date(job.capturedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time>
|
||||||
</div>
|
</div>
|
||||||
<strong class="job-result">${decodeText}</strong>
|
<strong class="job-result">${decodeText}</strong>
|
||||||
|
${palletDetail}
|
||||||
<div class="codes">${job.barcodes.map((code) => `<span class="code"><small>${escapeHtml(code.format)}</small>${escapeHtml(code.value)}</span>`).join('')}</div>
|
<div class="codes">${job.barcodes.map((code) => `<span class="code"><small>${escapeHtml(code.format)}</small>${escapeHtml(code.value)}</span>`).join('')}</div>
|
||||||
${job.error ? `<div class="job-error">${escapeHtml(job.error)}</div>` : ''}
|
${job.error ? `<div class="job-error">${escapeHtml(job.error)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -445,6 +445,50 @@ select:focus {
|
|||||||
.route-badge.test { background: #172c43; color: #9fcef5; }
|
.route-badge.test { background: #172c43; color: #9fcef5; }
|
||||||
.route-badge.neutral { background: #2a2519; color: #e3c47d; }
|
.route-badge.neutral { background: #2a2519; color: #e3c47d; }
|
||||||
|
|
||||||
|
.pallet-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pallet-badge.new-pallet { background: #123225; color: #8ce1b1; }
|
||||||
|
.pallet-badge.reprint-same { background: #2a2519; color: #e3c47d; }
|
||||||
|
.pallet-badge.reprint-changed { background: #411d1d; color: #ffb0b0; }
|
||||||
|
.pallet-badge.reprint-unreadable { background: #242a33; color: #b6c0cc; }
|
||||||
|
|
||||||
|
.pallet-print-detail {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin: -2px 0 7px;
|
||||||
|
color: #667386;
|
||||||
|
font-size: 9.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pallet-diff {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pallet-diff span {
|
||||||
|
border: 1px solid #573232;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
background: #241313;
|
||||||
|
color: #ffc1c1;
|
||||||
|
font: 10px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pallet-diff strong {
|
||||||
|
color: #f5d8d8;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
.job-result {
|
.job-result {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 9px 0 7px;
|
margin: 9px 0 7px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user