diff options
Diffstat (limited to 'src/core/operations')
| -rw-r--r-- | src/core/operations/asset_operations.rs | 613 | ||||
| -rw-r--r-- | src/core/operations/mod.rs | 4 |
2 files changed, 617 insertions, 0 deletions
diff --git a/src/core/operations/asset_operations.rs b/src/core/operations/asset_operations.rs new file mode 100644 index 0000000..459aeb5 --- /dev/null +++ b/src/core/operations/asset_operations.rs @@ -0,0 +1,613 @@ +use crate::api::ApiClient; +use crate::models::api_error_detail; +use serde_json::{Map, Value}; +/// Asset CRUD operations that can be reused across different entity types +pub struct AssetOperations; + +impl AssetOperations { + /// Apply updates to one or more assets + pub fn apply_updates<T>( + api: &ApiClient, + updated: Map<String, Value>, + pending_edit_ids: &mut Vec<i64>, + easy_dialog_item_id: Option<&str>, + advanced_dialog_item_id: Option<&str>, + find_asset_fn: impl Fn(i64) -> Option<T>, + limit: Option<u32>, + reload_fn: impl FnOnce(&ApiClient, Option<u32>), + ) where + T: serde::Serialize, + { + let ids: Vec<i64> = std::mem::take(pending_edit_ids); + log::info!("Pending edit IDs from ribbon: {:?}", ids); + + if ids.is_empty() { + // Try to get ID from either dialog or from the embedded ID in the diff + let item_id = updated + .get("__editor_item_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| easy_dialog_item_id.map(|s| s.to_string())) + .or_else(|| advanced_dialog_item_id.map(|s| s.to_string())); + + if let Some(id_str) = item_id { + if let Ok(id) = id_str.parse::<i64>() { + // Compute diff against the current asset so we only send changed fields + let only_changed = if let Some(orig) = find_asset_fn(id) { + log::info!("Found original asset data for comparison"); + let orig_value = serde_json::to_value(&orig).unwrap_or_default(); + let mut diff = Map::new(); + for (k, v) in updated.iter() { + match orig_value.get(k) { + Some(ov) if ov == v => { + log::debug!("Field '{}' unchanged: {:?}", k, v); + } + _ => { + log::info!( + "Field '{}' CHANGED: old={:?}, new={:?}", + k, + orig_value.get(k), + v + ); + diff.insert(k.clone(), v.clone()); + } + } + } + log::info!("Final diff map to send: {:?}", diff); + diff + } else { + log::warn!( + "Asset ID {} not found in local cache, sending full update", + id + ); + updated.clone() + }; + + if only_changed.is_empty() { + log::warn!("No changes detected - update will be skipped!"); + } else { + log::info!("Calling update_one with {} changes", only_changed.len()); + } + Self::update_one(api, id, &only_changed); + } else { + log::error!("FAILED to parse asset ID: '{}' - UPDATE SKIPPED!", id_str); + } + } else { + log::error!("NO ITEM ID FOUND - This is the bug! UPDATE COMPLETELY SKIPPED!"); + log::error!("Easy dialog item_id: {:?}", easy_dialog_item_id); + log::error!("Advanced dialog item_id: {:?}", advanced_dialog_item_id); + } + } else { + log::info!("Bulk edit mode for {} assets", ids.len()); + // Bulk edit: apply provided fields to each id without diffing per-record + for id in ids { + log::info!("Bulk updating asset ID: {}", id); + Self::update_one(api, id, &updated); + } + } + + reload_fn(api, limit); + } + + /// Handle quick-add fields for category, zone, and supplier when editing + pub fn preprocess_quick_adds(api: &ApiClient, data: &mut Map<String, Value>) { + // CATEGORY + let new_cat_name = data + .get("new_category_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let new_cat_code = data + .get("new_category_code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let has_selected_cat = data.get("category_id").and_then(|v| v.as_i64()).is_some() + || data + .get("category_id") + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if !has_selected_cat && !new_cat_name.is_empty() && !new_cat_code.is_empty() { + let values = serde_json::json!({ + "category_name": new_cat_name, + "category_code": new_cat_code, + }); + match api.insert("categories", values) { + Ok(resp) if resp.success => { + if let Some(id) = resp.data { + data.insert("category_id".into(), Value::Number((id as i64).into())); + } + } + Ok(resp) => { + log::error!( + "Quick-add category failed: {}", + api_error_detail(&resp.error) + ); + } + Err(e) => { + log::error!("Quick-add category err: {}", e); + } + } + } + + // ZONE + let new_zone_name = data + .get("new_zone_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + // Prefer new_zone_mini_code (new), fallback to legacy new_zone_code + let new_zone_mini_code = data + .get("new_zone_mini_code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let legacy_new_zone_code = data + .get("new_zone_code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let parent_id_val = data.get("new_zone_parent_id").cloned(); + let has_selected_zone = data.get("zone_id").and_then(|v| v.as_i64()).is_some() + || data + .get("zone_id") + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if !has_selected_zone && !new_zone_name.is_empty() { + // parent optional, parse int if provided + let mut zone_obj = Map::new(); + zone_obj.insert("zone_name".into(), Value::String(new_zone_name)); + // Determine mini_code to use + let mini_code = if !new_zone_mini_code.is_empty() { + new_zone_mini_code.clone() + } else { + legacy_new_zone_code.clone() + }; + if !mini_code.is_empty() { + // Compute full zone_code using parent if provided + let full_code = if let Some(v) = parent_id_val.clone() { + if let Some(pid) = v + .as_i64() + .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok())) + { + // Fetch parent's zone_code + if let Ok(resp) = api.select( + "zones", + Some(vec!["zone_code".into()]), + Some(serde_json::json!({"id": pid})), + None, + Some(1), + ) { + if resp.success { + if let Some(rows) = resp.data { + if let Some(row) = rows.into_iter().next() { + let pcode = row + .get("zone_code") + .and_then(|v| v.as_str()) + .unwrap_or(""); + format!("{}-{}", pcode, mini_code) + } else { + mini_code.clone() + } + } else { + mini_code.clone() + } + } else { + mini_code.clone() + } + } else { + mini_code.clone() + } + } else { + mini_code.clone() + } + } else { + mini_code.clone() + }; + // Send both mini_code and computed full zone_code + zone_obj.insert("mini_code".into(), Value::String(mini_code)); + zone_obj.insert("zone_code".into(), Value::String(full_code)); + } + if let Some(v) = parent_id_val { + if let Some(n) = v.as_i64() { + zone_obj.insert("parent_id".into(), Value::Number(n.into())); + } else if let Some(s) = v.as_str() { + if let Ok(n) = s.parse::<i64>() { + zone_obj.insert("parent_id".into(), Value::Number(n.into())); + } + } + } + match api.insert("zones", Value::Object(zone_obj)) { + Ok(resp) if resp.success => { + if let Some(id) = resp.data { + data.insert("zone_id".into(), Value::Number((id as i64).into())); + } + } + Ok(resp) => { + log::error!("Quick-add zone failed: {}", api_error_detail(&resp.error)); + } + Err(e) => { + log::error!("Quick-add zone err: {}", e); + } + } + } + + // SUPPLIER + let new_supplier_name = data + .get("new_supplier_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let has_selected_supplier = data.get("supplier_id").and_then(|v| v.as_i64()).is_some() + || data + .get("supplier_id") + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if !has_selected_supplier && !new_supplier_name.is_empty() { + let values = serde_json::json!({ "name": new_supplier_name }); + match api.insert("suppliers", values) { + Ok(resp) if resp.success => { + if let Some(id) = resp.data { + data.insert("supplier_id".into(), Value::Number((id as i64).into())); + } + } + Ok(resp) => { + log::error!( + "Quick-add supplier failed: {}", + api_error_detail(&resp.error) + ); + } + Err(e) => { + log::error!("Quick-add supplier err: {}", e); + } + } + } + } + + /// Filter update data to only include allowed fields with proper type coercion + pub fn filtered_update_fields(data: &Map<String, Value>) -> Map<String, Value> { + // Allow only writable/meaningful asset fields (exclude IDs, timestamps, joined names) + let allowed = [ + "asset_tag", + "asset_type", + "name", + "category_id", + "zone_id", + "zone_plus", + "zone_note", + "manufacturer", + "model", + "serial_number", + "status", + "label_template_id", + "price", + "purchase_date", + "warranty_until", + "expiry_date", + "supplier_id", + "lendable", + "lending_status", + "due_date", + "no_scan", + "quantity_available", + "quantity_total", + "quantity_used", + "minimum_role_for_lending", + "audit_task_id", + "asset_image", + "notes", + "additional_fields", + ]; + let allowed_set: std::collections::HashSet<&str> = allowed.iter().copied().collect(); + let mut out = Map::new(); + + for (k, v) in data.iter() { + // Skip internal editor fields + if k.starts_with("__editor_") { + continue; + } + // Map template-only "description" to asset "notes" to avoid DB column mismatch + if k == "description" { + let coerced = if let Some(s) = v.as_str() { + Value::String(s.to_string()) + } else if v.is_null() { + Value::Null + } else { + Value::String(v.to_string()) + }; + if !out.contains_key("notes") { + out.insert("notes".to_string(), coerced); + } + continue; + } + if !allowed_set.contains(k.as_str()) { + continue; + } + + // Coerce common types where Advanced Editor may send strings + let coerced = match k.as_str() { + // Integers (IDs and quantities) + "category_id" + | "zone_id" + | "label_template_id" + | "supplier_id" + | "audit_task_id" + | "quantity_available" + | "quantity_total" + | "quantity_used" + | "minimum_role_for_lending" => { + if let Some(n) = v.as_i64() { + Value::Number(n.into()) + } else if let Some(s) = v.as_str() { + if s.trim().is_empty() { + Value::Null + } else if let Ok(n) = s.trim().parse::<i64>() { + Value::Number(n.into()) + } else { + Value::Null + } + } else { + v.clone() + } + } + + // Booleans + "lendable" => { + if let Some(b) = v.as_bool() { + Value::Bool(b) + } else if let Some(s) = v.as_str() { + match s.trim().to_lowercase().as_str() { + "true" | "1" | "yes" => Value::Bool(true), + "false" | "0" | "no" => Value::Bool(false), + _ => v.clone(), + } + } else { + v.clone() + } + } + + // Price as decimal number + "price" => { + if let Some(f) = v.as_f64() { + Value::Number( + serde_json::Number::from_f64(f) + .unwrap_or_else(|| serde_json::Number::from(0)), + ) + } else if let Some(s) = v.as_str() { + if s.trim().is_empty() { + Value::Null + } else if let Ok(f) = s.trim().parse::<f64>() { + Value::Number( + serde_json::Number::from_f64(f) + .unwrap_or_else(|| serde_json::Number::from(0)), + ) + } else { + Value::Null + } + } else { + v.clone() + } + } + + // Date fields: accept YYYY-MM-DD strings; treat empty strings as NULL + "purchase_date" | "warranty_until" | "expiry_date" | "due_date" => { + if let Some(s) = v.as_str() { + let t = s.trim(); + if t.is_empty() { + Value::Null + } else { + Value::String(t.to_string()) + } + } else if v.is_null() { + Value::Null + } else { + // Fallback: stringify other types + Value::String(v.to_string()) + } + } + + // String fields - ensure they're strings (not null if empty) + "asset_tag" | "asset_type" | "name" | "manufacturer" | "model" + | "serial_number" | "status" | "zone_plus" | "zone_note" | "lending_status" + | "no_scan" | "notes" => { + if let Some(s) = v.as_str() { + Value::String(s.to_string()) + } else if v.is_null() { + Value::Null + } else { + Value::String(v.to_string()) + } + } + + _ => v.clone(), + }; + out.insert(k.clone(), coerced); + } + out + } + + /// Update a single entity record + pub fn update_one(api: &ApiClient, id: i64, data: &Map<String, Value>) { + Self::update_one_table(api, "assets", id, data); + } + + /// Update a single record in any table + pub fn update_one_table(api: &ApiClient, table: &str, id: i64, data: &Map<String, Value>) { + log::info!("=== UPDATE_ONE START for {} ID {} ===", table, id); + log::info!("Raw input data: {:?}", data); + + let values_map = Self::filtered_update_fields(data); + log::info!("Filtered values_map: {:?}", values_map); + + if values_map.is_empty() { + log::warn!( + "No allowed fields found after filtering for {} ID {}, SKIPPING UPDATE", + table, + id + ); + log::warn!("Original data keys: {:?}", data.keys().collect::<Vec<_>>()); + return; + } + + let values = Value::Object(values_map.clone()); + let where_clause = serde_json::json!({"id": id}); + + log::info!("SENDING UPDATE to server:"); + log::info!(" TABLE: {}", table); + log::info!(" WHERE: {:?}", where_clause); + log::info!(" VALUES: {:?}", values); + + match api.update(table, values, where_clause) { + Ok(resp) if resp.success => { + log::info!("Successfully updated {} ID {}", table, id); + } + Ok(resp) => { + log::error!( + "Server rejected update for {} ID {}: {}", + table, + id, + api_error_detail(&resp.error) + ); + } + Err(e) => { + log::error!("Network/API error updating {} ID {}: {}", table, id, e); + } + } + log::info!("=== UPDATE_ONE END ==="); + } + + /// Insert a new asset record with preprocessing and return its DB id (if available) + pub fn insert_new_asset( + api: &ApiClient, + mut data: Map<String, Value>, + limit: Option<u32>, + reload_fn: impl FnOnce(&ApiClient, Option<u32>), + ) -> Option<i64> { + log::info!("=== INSERT_NEW_ASSET START ==="); + log::info!("Raw asset data: {:?}", data); + + // Process quick-add fields first + Self::preprocess_quick_adds(api, &mut data); + + // Ensure mandatory defaults if missing or blank + let needs_default = |v: Option<&Value>| -> bool { + match v { + None => true, + Some(Value::Null) => true, + Some(Value::String(s)) => s.trim().is_empty(), + _ => false, + } + }; + if needs_default(data.get("asset_type")) { + data.insert("asset_type".into(), Value::String("N".to_string())); + } + if needs_default(data.get("status")) { + data.insert("status".into(), Value::String("Good".to_string())); + } + + // Filter to allowed fields + let filtered_data = Self::filtered_update_fields(&data); + log::info!("Filtered asset data: {:?}", filtered_data); + + if filtered_data.is_empty() { + log::error!("No valid data to insert"); + return None; + } + + let values = Value::Object(filtered_data); + log::info!("SENDING INSERT to server: {:?}", values); + + let result = match api.insert("assets", values) { + Ok(resp) if resp.success => { + log::info!("Successfully created new asset"); + let id = resp.data.map(|d| d as i64); + log::info!("New asset DB id from server: {:?}", id); + reload_fn(api, limit); + id + } + Ok(resp) => { + log::error!( + "Server rejected asset creation: {}", + api_error_detail(&resp.error) + ); + None + } + Err(e) => { + log::error!("Network/API error creating asset: {}", e); + None + } + }; + log::info!("=== INSERT_NEW_ASSET END ==="); + result + } + + /// Find an asset by ID in a collection + pub fn find_by_id<T>( + collection: &[T], + id: i64, + id_extractor: impl Fn(&T) -> Option<i64>, + ) -> Option<T> + where + T: Clone, + { + collection + .iter() + .find(|item| id_extractor(item) == Some(id)) + .cloned() + } + + /// Get selected IDs from a collection based on row indices + #[allow(dead_code)] + pub fn get_selected_ids<T>( + collection: &[T], + selected_rows: &std::collections::HashSet<usize>, + id_extractor: impl Fn(&T) -> Option<i64>, + ) -> Vec<i64> { + let mut ids = Vec::new(); + for &row in selected_rows { + if let Some(item) = collection.get(row) { + if let Some(id) = id_extractor(item) { + ids.push(id); + } + } + } + ids + } + + /// Filter and search through JSON data + #[allow(dead_code)] + pub fn filter_and_search( + data: &[Value], + search_query: &str, + search_fields: &[&str], + ) -> Vec<Value> { + if search_query.is_empty() { + return data.to_vec(); + } + + let search_lower = search_query.to_lowercase(); + data.iter() + .filter(|item| { + search_fields.iter().any(|field| { + item.get(field) + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&search_lower)) + .unwrap_or(false) + }) + }) + .cloned() + .collect() + } +} diff --git a/src/core/operations/mod.rs b/src/core/operations/mod.rs new file mode 100644 index 0000000..655e385 --- /dev/null +++ b/src/core/operations/mod.rs @@ -0,0 +1,4 @@ +/// Operations on assets and other entities +pub mod asset_operations; + +pub use asset_operations::*; |
