772 lines
28 KiB
Rust
772 lines
28 KiB
Rust
use crate::models::product::{Product, ProductCategory, ProductAvailability};
|
|
use crate::services::node_marketplace::NodeMarketplaceService;
|
|
use crate::services::user_persistence::UserPersistence;
|
|
use crate::services::currency::CurrencyService;
|
|
use rust_decimal::Decimal;
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Service for handling product operations
|
|
#[derive(Clone)]
|
|
pub struct ProductService {
|
|
currency_service: CurrencyService,
|
|
node_marketplace_service: NodeMarketplaceService,
|
|
include_slice_products: bool,
|
|
}
|
|
|
|
// Simple in-memory cache for aggregated catalog, keyed by include_slice_products flag
|
|
struct CacheEntry {
|
|
products: Vec<Product>,
|
|
fetched_at: Instant,
|
|
}
|
|
|
|
struct CatalogCache {
|
|
with_slice: Option<CacheEntry>,
|
|
without_slice: Option<CacheEntry>,
|
|
}
|
|
|
|
impl Default for CatalogCache {
|
|
fn default() -> Self {
|
|
Self { with_slice: None, without_slice: None }
|
|
}
|
|
}
|
|
|
|
static CATALOG_CACHE: OnceLock<Mutex<CatalogCache>> = OnceLock::new();
|
|
|
|
/// Product search and filter criteria
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProductSearchCriteria {
|
|
pub query: Option<String>,
|
|
pub category_id: Option<String>,
|
|
pub min_price: Option<Decimal>,
|
|
pub max_price: Option<Decimal>,
|
|
pub provider_id: Option<String>,
|
|
pub location: Option<String>,
|
|
pub tags: Vec<String>,
|
|
pub availability: Option<ProductAvailability>,
|
|
pub featured_only: bool,
|
|
pub attributes: HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
/// Product search results with metadata
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProductSearchResult {
|
|
pub products: Vec<Product>,
|
|
pub total_count: usize,
|
|
pub page: usize,
|
|
pub page_size: usize,
|
|
pub total_pages: usize,
|
|
pub filters_applied: ProductSearchCriteria,
|
|
}
|
|
|
|
impl ProductService {
|
|
pub fn new() -> Self {
|
|
let node_marketplace_service = NodeMarketplaceService::builder()
|
|
.build()
|
|
.expect("Failed to create NodeMarketplaceService");
|
|
|
|
Self {
|
|
currency_service: CurrencyService::new(),
|
|
node_marketplace_service,
|
|
include_slice_products: true,
|
|
}
|
|
}
|
|
|
|
pub fn new_with_config(
|
|
currency_service: CurrencyService,
|
|
_cache_enabled: bool,
|
|
_default_category: Option<String>,
|
|
) -> Self {
|
|
let node_marketplace_service = NodeMarketplaceService::builder()
|
|
.currency_service(currency_service.clone())
|
|
.build()
|
|
.expect("Failed to create NodeMarketplaceService");
|
|
|
|
Self {
|
|
currency_service,
|
|
node_marketplace_service,
|
|
include_slice_products: true,
|
|
}
|
|
}
|
|
|
|
pub fn new_with_slice_support(
|
|
currency_service: CurrencyService,
|
|
include_slice_products: bool,
|
|
) -> Self {
|
|
let node_marketplace_service = NodeMarketplaceService::builder()
|
|
.currency_service(currency_service.clone())
|
|
.build()
|
|
.expect("Failed to create NodeMarketplaceService");
|
|
|
|
Self {
|
|
currency_service,
|
|
node_marketplace_service,
|
|
include_slice_products,
|
|
}
|
|
}
|
|
|
|
pub fn builder() -> crate::models::builders::ProductServiceBuilder {
|
|
crate::models::builders::ProductServiceBuilder::new()
|
|
}
|
|
|
|
/// Get all products (includes fixtures/mock, user-created services, and optionally slice products)
|
|
pub fn get_all_products(&self) -> Vec<Product> {
|
|
let config = crate::config::get_app_config();
|
|
|
|
if config.is_catalog_cache_enabled() {
|
|
let ttl = Duration::from_secs(config.catalog_cache_ttl_secs());
|
|
let now = Instant::now();
|
|
let cache = CATALOG_CACHE.get_or_init(|| Mutex::new(CatalogCache::default()));
|
|
let mut guard = cache.lock().unwrap();
|
|
|
|
let entry_opt = if self.include_slice_products { &mut guard.with_slice } else { &mut guard.without_slice };
|
|
if let Some(entry) = entry_opt {
|
|
if now.duration_since(entry.fetched_at) < ttl {
|
|
return entry.products.clone();
|
|
}
|
|
}
|
|
|
|
// Cache miss or expired
|
|
let products = self.aggregate_all_products_uncached();
|
|
let new_entry = CacheEntry { products: products.clone(), fetched_at: now };
|
|
if self.include_slice_products {
|
|
guard.with_slice = Some(new_entry);
|
|
} else {
|
|
guard.without_slice = Some(new_entry);
|
|
}
|
|
return products;
|
|
}
|
|
|
|
// Cache disabled
|
|
self.aggregate_all_products_uncached()
|
|
}
|
|
|
|
/// Compute the full aggregated catalog without using the cache
|
|
fn aggregate_all_products_uncached(&self) -> Vec<Product> {
|
|
let mut all_products = Vec::new();
|
|
let config = crate::config::get_app_config();
|
|
|
|
// Prefer fixtures when configured
|
|
if config.is_fixtures() {
|
|
let fixture_products = self.load_fixture_products();
|
|
all_products.extend(fixture_products);
|
|
}
|
|
// Mock data support removed - using only fixtures and user persistent data
|
|
|
|
// Get user-created products (applications/services created via Service Provider dashboard)
|
|
// Note: System has migrated from services to products - only use product-based approach
|
|
let user_products = UserPersistence::get_all_users_products();
|
|
println!("🔍 PRODUCT SERVICE: Found {} user products", user_products.len());
|
|
for product in &user_products {
|
|
println!("🔍 PRODUCT SERVICE: User product: {} (category: {}, provider: {})",
|
|
product.name, product.category_id, product.provider_id);
|
|
}
|
|
all_products.extend(user_products);
|
|
println!("🔍 PRODUCT SERVICE: Total products after adding user products: {}", all_products.len());
|
|
|
|
// Get slice products if enabled
|
|
if self.include_slice_products {
|
|
let slice_products = self.node_marketplace_service.get_all_slice_combinations();
|
|
all_products.extend(slice_products);
|
|
}
|
|
|
|
// Normalize categories across all sources to canonical forms
|
|
for p in all_products.iter_mut() {
|
|
let normalized = Self::canonical_category_id(&p.category_id);
|
|
p.category_id = normalized;
|
|
}
|
|
|
|
// Deduplicate by product ID, preferring later sources (user-owned) over earlier seeds/mocks
|
|
// Strategy: reverse iterate so last occurrence wins, then reverse back to preserve overall order
|
|
let mut seen_ids = std::collections::HashSet::new();
|
|
let mut unique_rev = Vec::with_capacity(all_products.len());
|
|
for p in all_products.into_iter().rev() {
|
|
if seen_ids.insert(p.id.clone()) {
|
|
unique_rev.push(p);
|
|
}
|
|
}
|
|
unique_rev.reverse();
|
|
unique_rev
|
|
}
|
|
|
|
/// Get product by ID using the aggregated, de-duplicated catalog
|
|
pub fn get_product_by_id(&self, id: &str) -> Option<Product> {
|
|
self.get_all_products()
|
|
.into_iter()
|
|
.find(|p| p.id == id)
|
|
}
|
|
/// Get slice products only
|
|
pub fn get_slice_products(&self) -> Vec<Product> {
|
|
if self.include_slice_products {
|
|
self.node_marketplace_service.get_all_slice_combinations()
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
/// Check if a product is a slice product
|
|
pub fn is_slice_product(&self, product_id: &str) -> bool {
|
|
if !self.include_slice_products {
|
|
return false;
|
|
}
|
|
|
|
// Slice products have IDs that start with "slice_" or contain slice-specific patterns
|
|
product_id.starts_with("slice_") ||
|
|
(product_id.contains("x") && product_id.chars().any(|c| c.is_numeric()))
|
|
}
|
|
|
|
/// Get slice product details with deployment information
|
|
pub fn get_slice_product_details(&self, product_id: &str) -> Option<serde_json::Value> {
|
|
if let Some(product) = self.get_product_by_id(product_id) {
|
|
if self.is_slice_product(product_id) {
|
|
// Extract slice-specific information for deployment
|
|
let mut details = serde_json::json!({
|
|
"id": product.id,
|
|
"name": product.name,
|
|
"description": product.description,
|
|
"price": product.base_price,
|
|
"currency": product.base_currency,
|
|
"provider": product.provider_name,
|
|
"category": "compute_slice",
|
|
"is_slice_product": true
|
|
});
|
|
|
|
// Add slice-specific attributes
|
|
if let Some(node_id) = product.attributes.get("node_id") {
|
|
details["node_id"] = node_id.value.clone();
|
|
}
|
|
if let Some(combination_id) = product.attributes.get("combination_id") {
|
|
details["combination_id"] = combination_id.value.clone();
|
|
}
|
|
if let Some(resource_provider_email) = product.attributes.get("resource_provider_email") {
|
|
details["resource_provider_email"] = resource_provider_email.value.clone();
|
|
}
|
|
if let Some(cpu_cores) = product.attributes.get("cpu_cores") {
|
|
details["cpu_cores"] = cpu_cores.value.clone();
|
|
}
|
|
if let Some(memory_gb) = product.attributes.get("memory_gb") {
|
|
details["memory_gb"] = memory_gb.value.clone();
|
|
}
|
|
if let Some(storage_gb) = product.attributes.get("storage_gb") {
|
|
details["storage_gb"] = storage_gb.value.clone();
|
|
}
|
|
if let Some(location) = product.attributes.get("location") {
|
|
details["location"] = location.value.clone();
|
|
}
|
|
|
|
return Some(details);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Apply filters to slice products
|
|
pub fn get_filtered_slice_products(&self, filters: &HashMap<String, String>) -> Vec<Product> {
|
|
if !self.include_slice_products {
|
|
return Vec::new();
|
|
}
|
|
|
|
let slice_products = self.node_marketplace_service.get_all_slice_combinations();
|
|
self.node_marketplace_service.apply_slice_filters(&slice_products, filters)
|
|
}
|
|
|
|
|
|
/// Get products by category
|
|
pub fn get_products_by_category(&self, category_id: &str) -> Vec<Product> {
|
|
self.get_all_products()
|
|
.into_iter()
|
|
.filter(|p| p.category_id == category_id)
|
|
.collect()
|
|
}
|
|
|
|
/// Get featured products
|
|
pub fn get_featured_products(&self) -> Vec<Product> {
|
|
self.get_all_products()
|
|
.into_iter()
|
|
.filter(|p| p.metadata.featured)
|
|
.collect()
|
|
}
|
|
|
|
/// Get products by provider
|
|
pub fn get_products_by_provider(&self, provider_id: &str) -> Vec<Product> {
|
|
self.get_all_products()
|
|
.into_iter()
|
|
.filter(|p| p.provider_id == provider_id)
|
|
.collect()
|
|
}
|
|
|
|
/// Search products with basic text query
|
|
pub fn search_products(&self, query: &str) -> Vec<Product> {
|
|
let query_lower = query.to_lowercase();
|
|
self.get_all_products()
|
|
.into_iter()
|
|
.filter(|p| {
|
|
p.name.to_lowercase().contains(&query_lower) ||
|
|
p.description.to_lowercase().contains(&query_lower) ||
|
|
p.metadata.tags.iter().any(|tag| tag.to_lowercase().contains(&query_lower)) ||
|
|
p.provider_name.to_lowercase().contains(&query_lower)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Advanced product search with multiple criteria
|
|
pub fn search_products_advanced(
|
|
&self,
|
|
criteria: &ProductSearchCriteria,
|
|
page: usize,
|
|
page_size: usize,
|
|
) -> ProductSearchResult {
|
|
let all = self.get_all_products();
|
|
let mut products: Vec<&Product> = all.iter().collect();
|
|
|
|
// Apply filters
|
|
if let Some(ref query) = criteria.query {
|
|
products = self.filter_by_text_query(products, query);
|
|
}
|
|
|
|
if let Some(ref category_id) = criteria.category_id {
|
|
products = self.filter_by_category(products, category_id);
|
|
}
|
|
|
|
if let Some(min_price) = criteria.min_price {
|
|
products = self.filter_by_min_price(products, min_price);
|
|
}
|
|
|
|
if let Some(max_price) = criteria.max_price {
|
|
products = self.filter_by_max_price(products, max_price);
|
|
}
|
|
|
|
if let Some(ref provider_id) = criteria.provider_id {
|
|
products = self.filter_by_provider(products, provider_id);
|
|
}
|
|
|
|
if let Some(ref location) = criteria.location {
|
|
products = self.filter_by_location(products, location);
|
|
}
|
|
|
|
if !criteria.tags.is_empty() {
|
|
products = self.filter_by_tags(products, &criteria.tags);
|
|
}
|
|
|
|
if let Some(ref availability) = criteria.availability {
|
|
products = self.filter_by_availability(products, availability);
|
|
}
|
|
|
|
if criteria.featured_only {
|
|
products = self.filter_featured_only(products);
|
|
}
|
|
|
|
if !criteria.attributes.is_empty() {
|
|
products = self.filter_by_attributes(products, &criteria.attributes);
|
|
}
|
|
|
|
let total_count = products.len();
|
|
let total_pages = (total_count + page_size - 1) / page_size;
|
|
|
|
// Apply pagination
|
|
let start_idx = page * page_size;
|
|
let end_idx = std::cmp::min(start_idx + page_size, total_count);
|
|
let paginated_products: Vec<Product> = products[start_idx..end_idx]
|
|
.iter()
|
|
.map(|&p| p.clone())
|
|
.collect();
|
|
|
|
ProductSearchResult {
|
|
products: paginated_products,
|
|
total_count,
|
|
page,
|
|
page_size,
|
|
total_pages,
|
|
filters_applied: criteria.clone(),
|
|
}
|
|
}
|
|
|
|
/// Get all product categories
|
|
pub fn get_categories(&self) -> Vec<ProductCategory> {
|
|
let config = crate::config::get_app_config();
|
|
if config.is_fixtures() {
|
|
let products = self.get_all_products();
|
|
self.derive_categories(&products)
|
|
} else {
|
|
// Mock data support removed - using only fixtures and user persistent data
|
|
let products = self.get_all_products();
|
|
self.derive_categories(&products)
|
|
}
|
|
}
|
|
|
|
/// Get category by ID
|
|
pub fn get_category_by_id(&self, id: &str) -> Option<ProductCategory> {
|
|
self.get_categories().into_iter().find(|c| c.id == id)
|
|
}
|
|
|
|
/// Get products with prices converted to specified currency
|
|
pub fn get_products_with_converted_prices(
|
|
&self,
|
|
products: &[Product],
|
|
display_currency: &str,
|
|
) -> Result<Vec<(Product, crate::models::currency::Price)>, String> {
|
|
let mut result = Vec::default();
|
|
|
|
for product in products {
|
|
let price = self.currency_service.create_price(
|
|
product.base_price,
|
|
&product.base_currency,
|
|
display_currency,
|
|
)?;
|
|
result.push((product.clone(), price));
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Get product recommendations based on a product
|
|
pub fn get_product_recommendations(&self, product_id: &str, limit: usize) -> Vec<Product> {
|
|
if let Some(product) = self.get_product_by_id(product_id) {
|
|
// Simple recommendation logic: same category, different products
|
|
let mut recommendations: Vec<Product> = self.get_products_by_category(&product.category_id)
|
|
.into_iter()
|
|
.filter(|p| p.id != product_id)
|
|
.collect();
|
|
|
|
// Sort by rating and featured status
|
|
recommendations.sort_by(|a, b| {
|
|
let a_score = self.calculate_recommendation_score(a);
|
|
let b_score = self.calculate_recommendation_score(b);
|
|
b_score.partial_cmp(&a_score).unwrap_or(std::cmp::Ordering::Equal)
|
|
});
|
|
|
|
recommendations.into_iter().take(limit).collect()
|
|
} else {
|
|
Vec::default()
|
|
}
|
|
}
|
|
|
|
/// Get product statistics
|
|
pub fn get_product_statistics(&self) -> HashMap<String, serde_json::Value> {
|
|
let products = self.get_all_products();
|
|
let categories = self.get_categories();
|
|
|
|
let mut stats = HashMap::default();
|
|
|
|
// Basic counts
|
|
stats.insert("total_products".to_string(),
|
|
serde_json::Value::Number(serde_json::Number::from(products.len())));
|
|
stats.insert("total_categories".to_string(),
|
|
serde_json::Value::Number(serde_json::Number::from(categories.len())));
|
|
|
|
// Featured products count
|
|
let featured_count = products.iter().filter(|p| p.metadata.featured).count();
|
|
stats.insert("featured_products".to_string(),
|
|
serde_json::Value::Number(serde_json::Number::from(featured_count)));
|
|
|
|
// Products by category
|
|
let mut category_counts: HashMap<String, i32> = HashMap::default();
|
|
for product in &products {
|
|
*category_counts.entry(product.category_id.clone()).or_insert(0) += 1;
|
|
}
|
|
|
|
let category_stats: Vec<serde_json::Value> = category_counts.iter()
|
|
.map(|(category_id, count)| {
|
|
let category_name = self.get_category_by_id(category_id)
|
|
.map(|c| c.display_name.clone())
|
|
.unwrap_or_else(|| category_id.to_string());
|
|
serde_json::json!({
|
|
"category_id": category_id,
|
|
"category_name": category_name,
|
|
"product_count": count
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
stats.insert("products_by_category".to_string(), serde_json::Value::Array(category_stats));
|
|
|
|
// Price statistics
|
|
if !products.is_empty() {
|
|
let prices: Vec<Decimal> = products.iter().map(|p| p.base_price).collect();
|
|
let min_price = prices.iter().min().unwrap();
|
|
let max_price = prices.iter().max().unwrap();
|
|
let avg_price = prices.iter().sum::<Decimal>() / Decimal::from(prices.len());
|
|
|
|
let currency = self.currency_service.get_base_currency().code.clone();
|
|
stats.insert("price_range".to_string(), serde_json::json!({
|
|
"min": min_price.to_string(),
|
|
"max": max_price.to_string(),
|
|
"average": avg_price.to_string(),
|
|
"currency": currency
|
|
}));
|
|
}
|
|
|
|
// Provider statistics
|
|
let mut provider_counts: HashMap<String, i32> = HashMap::default();
|
|
for product in &products {
|
|
*provider_counts.entry(product.provider_id.clone()).or_insert(0) += 1;
|
|
}
|
|
|
|
stats.insert("total_providers".to_string(),
|
|
serde_json::Value::Number(serde_json::Number::from(provider_counts.len())));
|
|
|
|
stats
|
|
}
|
|
|
|
// Private helper methods for filtering
|
|
|
|
fn filter_by_text_query<'a>(&self, products: Vec<&'a Product>, query: &str) -> Vec<&'a Product> {
|
|
let query_lower = query.to_lowercase();
|
|
products.into_iter()
|
|
.filter(|p| {
|
|
p.name.to_lowercase().contains(&query_lower) ||
|
|
p.description.to_lowercase().contains(&query_lower) ||
|
|
p.metadata.tags.iter().any(|tag| tag.to_lowercase().contains(&query_lower)) ||
|
|
p.provider_name.to_lowercase().contains(&query_lower)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn filter_by_category<'a>(&self, products: Vec<&'a Product>, category_id: &str) -> Vec<&'a Product> {
|
|
products.into_iter()
|
|
.filter(|p| p.category_id == category_id)
|
|
.collect()
|
|
}
|
|
|
|
fn filter_by_min_price<'a>(&self, products: Vec<&'a Product>, min_price: Decimal) -> Vec<&'a Product> {
|
|
products.into_iter()
|
|
.filter(|p| p.base_price >= min_price)
|
|
.collect()
|
|
}
|
|
|
|
fn filter_by_max_price<'a>(&self, products: Vec<&'a Product>, max_price: Decimal) -> Vec<&'a Product> {
|
|
products.into_iter()
|
|
.filter(|p| p.base_price <= max_price)
|
|
.collect()
|
|
}
|
|
|
|
fn filter_by_provider<'a>(&self, products: Vec<&'a Product>, provider_id: &str) -> Vec<&'a Product> {
|
|
products.into_iter()
|
|
.filter(|p| p.provider_id == provider_id)
|
|
.collect()
|
|
}
|
|
|
|
fn filter_by_location<'a>(&self, products: Vec<&'a Product>, location: &str) -> Vec<&'a Product> {
|
|
let location_lower = location.to_lowercase();
|
|
products.into_iter()
|
|
.filter(|p| {
|
|
p.metadata.location.as_ref()
|
|
.map(|loc| loc.to_lowercase().contains(&location_lower))
|
|
.unwrap_or(false) ||
|
|
p.attributes.get("location")
|
|
.and_then(|v| v.value.as_str())
|
|
.map(|loc| loc.to_lowercase().contains(&location_lower))
|
|
.unwrap_or(false)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn filter_by_tags<'a>(&self, products: Vec<&'a Product>, tags: &[String]) -> Vec<&'a Product> {
|
|
products.into_iter()
|
|
.filter(|p| {
|
|
tags.iter().any(|tag| p.metadata.tags.contains(tag))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn filter_by_availability<'a>(&self, products: Vec<&'a Product>, availability: &ProductAvailability) -> Vec<&'a Product> {
|
|
products.into_iter()
|
|
.filter(|p| std::mem::discriminant(&p.availability) == std::mem::discriminant(availability))
|
|
.collect()
|
|
}
|
|
|
|
fn filter_featured_only<'a>(&self, products: Vec<&'a Product>) -> Vec<&'a Product> {
|
|
products.into_iter()
|
|
.filter(|p| p.metadata.featured)
|
|
.collect()
|
|
}
|
|
|
|
fn filter_by_attributes<'a>(
|
|
&self,
|
|
products: Vec<&'a Product>,
|
|
attributes: &HashMap<String, serde_json::Value>,
|
|
) -> Vec<&'a Product> {
|
|
products.into_iter()
|
|
.filter(|p| {
|
|
attributes.iter().all(|(key, value)| {
|
|
p.attributes.get(key)
|
|
.map(|attr| &attr.value == value)
|
|
.unwrap_or(false)
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn calculate_recommendation_score(&self, product: &Product) -> f32 {
|
|
let mut score = 0.0;
|
|
|
|
// Featured products get higher score
|
|
if product.metadata.featured {
|
|
score += 10.0;
|
|
}
|
|
|
|
// Products with ratings get score based on rating
|
|
if let Some(rating) = product.metadata.rating {
|
|
score += rating * 2.0;
|
|
}
|
|
|
|
// Products with more reviews get slight boost
|
|
score += (product.metadata.review_count as f32).ln().max(0.0);
|
|
|
|
score
|
|
}
|
|
}
|
|
|
|
impl ProductService {
|
|
/// Load products from fixtures directory (products.json). Returns empty vec on error.
|
|
fn load_fixture_products(&self) -> Vec<Product> {
|
|
let config = crate::config::get_app_config();
|
|
let mut path = PathBuf::from(config.fixtures_path());
|
|
path.push("products.json");
|
|
|
|
match fs::read_to_string(&path) {
|
|
Ok(content) => {
|
|
match serde_json::from_str::<Vec<Product>>(&content) {
|
|
Ok(mut products) => {
|
|
// Normalize category IDs from fixtures to canonical singular forms
|
|
for p in products.iter_mut() {
|
|
let normalized = Self::canonical_category_id(&p.category_id);
|
|
p.category_id = normalized;
|
|
}
|
|
products
|
|
},
|
|
Err(e) => {
|
|
eprintln!("WARN: Failed to parse fixtures file {}: {}", path.display(), e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("INFO: Fixtures file not found or unreadable ({}): {}", path.display(), e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Map various plural/alias category IDs to canonical singular IDs used across the app
|
|
fn canonical_category_id(category_id: &str) -> String {
|
|
match category_id.to_lowercase().as_str() {
|
|
// Applications
|
|
"applications" | "application" | "app" | "apps" => "application".to_string(),
|
|
// Gateways
|
|
"gateways" | "gateway" => "gateway".to_string(),
|
|
// Services
|
|
"services" | "service" => "service".to_string(),
|
|
// Professional service subcategories should map to the generic "service"
|
|
"consulting" | "deployment" | "support" | "training" | "development" | "maintenance"
|
|
| "professional_services" | "professional_service" | "professional services" | "professional service"
|
|
| "system administration" | "system_administration" | "sysadmin" => "service".to_string(),
|
|
// Compute
|
|
"computes" | "compute" => "compute".to_string(),
|
|
// Storage often modeled as a service in current UI
|
|
"storage" | "storages" => "service".to_string(),
|
|
other => other.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Derive minimal categories from available products
|
|
fn derive_categories(&self, products: &[Product]) -> Vec<ProductCategory> {
|
|
use std::collections::HashSet;
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
let mut categories = Vec::new();
|
|
|
|
for p in products {
|
|
if seen.insert(p.category_id.clone()) {
|
|
categories.push(ProductCategory {
|
|
id: p.category_id.clone(),
|
|
name: p.category_id.clone(),
|
|
display_name: p.category_id.clone(),
|
|
description: String::new(),
|
|
attribute_schema: Vec::new(),
|
|
parent_category: None,
|
|
is_active: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
categories
|
|
}
|
|
}
|
|
|
|
impl Default for ProductService {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Default for ProductSearchCriteria {
|
|
fn default() -> Self {
|
|
Self {
|
|
query: None,
|
|
category_id: None,
|
|
min_price: None,
|
|
max_price: None,
|
|
provider_id: None,
|
|
location: None,
|
|
tags: Vec::default(),
|
|
availability: None,
|
|
featured_only: false,
|
|
attributes: HashMap::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ProductSearchCriteria {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn with_query(mut self, query: String) -> Self {
|
|
self.query = Some(query);
|
|
self
|
|
}
|
|
|
|
pub fn with_category(mut self, category_id: String) -> Self {
|
|
self.category_id = Some(category_id);
|
|
self
|
|
}
|
|
|
|
pub fn with_price_range(mut self, min_price: Option<Decimal>, max_price: Option<Decimal>) -> Self {
|
|
self.min_price = min_price;
|
|
self.max_price = max_price;
|
|
self
|
|
}
|
|
|
|
pub fn with_provider(mut self, provider_id: String) -> Self {
|
|
self.provider_id = Some(provider_id);
|
|
self
|
|
}
|
|
|
|
pub fn with_location(mut self, location: String) -> Self {
|
|
self.location = Some(location);
|
|
self
|
|
}
|
|
|
|
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
|
|
self.tags = tags;
|
|
self
|
|
}
|
|
|
|
pub fn with_availability(mut self, availability: ProductAvailability) -> Self {
|
|
self.availability = Some(availability);
|
|
self
|
|
}
|
|
|
|
pub fn featured_only(mut self) -> Self {
|
|
self.featured_only = true;
|
|
self
|
|
}
|
|
|
|
pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
|
|
self.attributes.insert(key, value);
|
|
self
|
|
}
|
|
} |