aboutsummaryrefslogtreecommitdiff
path: root/src/core/operations/asset_operations.rs
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
committerUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
commit8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch)
treeffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/core/operations/asset_operations.rs
committing to insanityHEADmaster
Diffstat (limited to 'src/core/operations/asset_operations.rs')
-rw-r--r--src/core/operations/asset_operations.rs613
1 files changed, 613 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()
+ }
+}