init projectmycelium
This commit is contained in:
772
src/services/product.rs
Normal file
772
src/services/product.rs
Normal file
@@ -0,0 +1,772 @@
|
||||
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(farmer_email) = product.attributes.get("farmer_email") {
|
||||
details["farmer_email"] = farmer_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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user