littleshop/LittleShop/Services/ProductService.cs
SysAdmin 8d1e3d153c Fix: Manually load ProductVariants with separate query instead of Include
**Root Cause**: EF Core Include() was not properly materializing the Variants navigation
property despite correct SQL JOIN generation.

**Solution**: Load variants separately and manually group by ProductId for DTO mapping.
This bypasses EF Core's navigation property fixup issues.

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 16:33:33 +01:00

728 lines
26 KiB
C#

using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.Models;
using LittleShop.DTOs;
namespace LittleShop.Services;
public class ProductService : IProductService
{
private readonly LittleShopContext _context;
private readonly IWebHostEnvironment _environment;
public ProductService(LittleShopContext context, IWebHostEnvironment environment)
{
_context = context;
_environment = environment;
}
public async Task<IEnumerable<ProductDto>> GetAllProductsAsync()
{
var products = await _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.MultiBuys)
.Where(p => p.IsActive)
.ToListAsync();
// Manually load variants for all products
var productIds = products.Select(p => p.Id).ToList();
var allVariants = await _context.ProductVariants
.Where(v => productIds.Contains(v.ProductId))
.ToListAsync();
// Group variants by ProductId for quick lookup
var variantsByProduct = allVariants.GroupBy(v => v.ProductId)
.ToDictionary(g => g.Key, g => g.ToList());
return products.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Weight = p.Weight,
WeightUnit = p.WeightUnit,
StockQuantity = p.StockQuantity,
CategoryId = p.CategoryId,
CategoryName = p.Category.Name,
CreatedAt = p.CreatedAt,
UpdatedAt = p.UpdatedAt,
IsActive = p.IsActive,
Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
{
Id = ph.Id,
FileName = ph.FileName,
FilePath = ph.FilePath,
AltText = ph.AltText,
SortOrder = ph.SortOrder
}).ToList(),
MultiBuys = p.MultiBuys.Select(mb => new ProductMultiBuyDto
{
Id = mb.Id,
ProductId = mb.ProductId,
Name = mb.Name,
Description = mb.Description,
Quantity = mb.Quantity,
Price = mb.Price,
PricePerUnit = mb.PricePerUnit,
SortOrder = mb.SortOrder,
IsActive = mb.IsActive,
CreatedAt = mb.CreatedAt,
UpdatedAt = mb.UpdatedAt
}).ToList(),
Variants = variantsByProduct.ContainsKey(p.Id)
? variantsByProduct[p.Id].Select(v => new ProductVariantDto
{
Id = v.Id,
ProductId = v.ProductId,
VariantType = v.VariantType,
Name = v.Name,
Price = v.Price,
StockLevel = v.StockLevel,
SortOrder = v.SortOrder,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
}).ToList()
: new List<ProductVariantDto>()
}).ToList();
}
public async Task<IEnumerable<ProductDto>> GetProductsByCategoryAsync(Guid categoryId)
{
var products = await _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.MultiBuys)
.Where(p => p.IsActive && p.CategoryId == categoryId)
.ToListAsync();
// Manually load variants for all products
var productIds = products.Select(p => p.Id).ToList();
var allVariants = await _context.ProductVariants
.Where(v => productIds.Contains(v.ProductId))
.ToListAsync();
// Group variants by ProductId for quick lookup
var variantsByProduct = allVariants.GroupBy(v => v.ProductId)
.ToDictionary(g => g.Key, g => g.ToList());
return products.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Weight = p.Weight,
WeightUnit = p.WeightUnit,
StockQuantity = p.StockQuantity,
CategoryId = p.CategoryId,
CategoryName = p.Category.Name,
CreatedAt = p.CreatedAt,
UpdatedAt = p.UpdatedAt,
IsActive = p.IsActive,
Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
{
Id = ph.Id,
FileName = ph.FileName,
FilePath = ph.FilePath,
AltText = ph.AltText,
SortOrder = ph.SortOrder
}).ToList(),
MultiBuys = p.MultiBuys.Select(mb => new ProductMultiBuyDto
{
Id = mb.Id,
ProductId = mb.ProductId,
Name = mb.Name,
Description = mb.Description,
Quantity = mb.Quantity,
Price = mb.Price,
PricePerUnit = mb.PricePerUnit,
SortOrder = mb.SortOrder,
IsActive = mb.IsActive,
CreatedAt = mb.CreatedAt,
UpdatedAt = mb.UpdatedAt
}).ToList(),
Variants = variantsByProduct.ContainsKey(p.Id)
? variantsByProduct[p.Id].Select(v => new ProductVariantDto
{
Id = v.Id,
ProductId = v.ProductId,
VariantType = v.VariantType,
Name = v.Name,
Price = v.Price,
StockLevel = v.StockLevel,
SortOrder = v.SortOrder,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
}).ToList()
: new List<ProductVariantDto>()
}).ToList();
}
public async Task<ProductDto?> GetProductByIdAsync(Guid id)
{
var product = await _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.MultiBuys)
.Include(p => p.Variants)
.FirstOrDefaultAsync(p => p.Id == id);
if (product == null) return null;
return new ProductDto
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Weight = product.Weight,
WeightUnit = product.WeightUnit,
StockQuantity = product.StockQuantity,
CategoryId = product.CategoryId,
CategoryName = product.Category.Name,
VariantCollectionId = product.VariantCollectionId,
VariantsJson = product.VariantsJson,
CreatedAt = product.CreatedAt,
UpdatedAt = product.UpdatedAt,
IsActive = product.IsActive,
Photos = product.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
{
Id = ph.Id,
FileName = ph.FileName,
FilePath = ph.FilePath,
AltText = ph.AltText,
SortOrder = ph.SortOrder
}).ToList(),
MultiBuys = product.MultiBuys.OrderBy(v => v.SortOrder).Select(v => new ProductMultiBuyDto
{
Id = v.Id,
ProductId = v.ProductId,
Name = v.Name,
Description = v.Description,
Quantity = v.Quantity,
Price = v.Price,
PricePerUnit = v.PricePerUnit,
SortOrder = v.SortOrder,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
}).ToList(),
Variants = product.Variants.OrderBy(v => v.SortOrder).Select(v => new ProductVariantDto
{
Id = v.Id,
ProductId = v.ProductId,
VariantType = v.VariantType,
Name = v.Name,
Price = v.Price,
StockLevel = v.StockLevel,
SortOrder = v.SortOrder,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
}).ToList()
};
}
public async Task<ProductDto> CreateProductAsync(CreateProductDto createProductDto)
{
var product = new Product
{
Id = Guid.NewGuid(),
Name = createProductDto.Name,
Description = string.IsNullOrEmpty(createProductDto.Description) ? " " : createProductDto.Description,
Price = createProductDto.Price,
Weight = createProductDto.Weight,
WeightUnit = createProductDto.WeightUnit,
StockQuantity = createProductDto.StockQuantity,
CategoryId = createProductDto.CategoryId,
VariantCollectionId = createProductDto.VariantCollectionId,
VariantsJson = createProductDto.VariantsJson,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
IsActive = true
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
var category = await _context.Categories.FindAsync(createProductDto.CategoryId);
return new ProductDto
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Weight = product.Weight,
WeightUnit = product.WeightUnit,
CategoryId = product.CategoryId,
CategoryName = category?.Name ?? "",
CreatedAt = product.CreatedAt,
UpdatedAt = product.UpdatedAt,
IsActive = product.IsActive,
Photos = new List<ProductPhotoDto>(),
MultiBuys = new List<ProductMultiBuyDto>(),
Variants = new List<ProductVariantDto>()
};
}
public async Task<bool> UpdateProductAsync(Guid id, UpdateProductDto updateProductDto)
{
var product = await _context.Products.FindAsync(id);
if (product == null) return false;
if (!string.IsNullOrEmpty(updateProductDto.Name))
product.Name = updateProductDto.Name;
if (!string.IsNullOrEmpty(updateProductDto.Description))
product.Description = updateProductDto.Description;
if (updateProductDto.Price.HasValue)
product.Price = updateProductDto.Price.Value;
if (updateProductDto.Weight.HasValue)
product.Weight = updateProductDto.Weight.Value;
if (updateProductDto.WeightUnit.HasValue)
product.WeightUnit = updateProductDto.WeightUnit.Value;
if (updateProductDto.StockQuantity.HasValue)
product.StockQuantity = updateProductDto.StockQuantity.Value;
if (updateProductDto.CategoryId.HasValue)
product.CategoryId = updateProductDto.CategoryId.Value;
if (updateProductDto.VariantCollectionId.HasValue)
product.VariantCollectionId = updateProductDto.VariantCollectionId.Value;
if (updateProductDto.VariantsJson != null)
product.VariantsJson = updateProductDto.VariantsJson;
if (updateProductDto.IsActive.HasValue)
product.IsActive = updateProductDto.IsActive.Value;
product.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> DeleteProductAsync(Guid id)
{
var product = await _context.Products.FindAsync(id);
if (product == null) return false;
product.IsActive = false;
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> AddProductPhotoAsync(Guid productId, IFormFile file, string? altText = null)
{
var product = await _context.Products.FindAsync(productId);
if (product == null) return false;
var uploadsPath = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", "products");
Directory.CreateDirectory(uploadsPath);
var fileName = $"{Guid.NewGuid()}_{file.FileName}";
var filePath = Path.Combine(uploadsPath, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
var maxSortOrder = await _context.ProductPhotos
.Where(pp => pp.ProductId == productId)
.Select(pp => (int?)pp.SortOrder)
.MaxAsync() ?? 0;
var productPhoto = new ProductPhoto
{
Id = Guid.NewGuid(),
ProductId = productId,
FileName = fileName,
FilePath = $"/uploads/products/{fileName}",
AltText = altText,
SortOrder = maxSortOrder + 1,
CreatedAt = DateTime.UtcNow
};
_context.ProductPhotos.Add(productPhoto);
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> RemoveProductPhotoAsync(Guid productId, Guid photoId)
{
var photo = await _context.ProductPhotos
.FirstOrDefaultAsync(pp => pp.Id == photoId && pp.ProductId == productId);
if (photo == null) return false;
var physicalPath = Path.Combine(_environment.WebRootPath, photo.FilePath.TrimStart('/'));
if (File.Exists(physicalPath))
{
File.Delete(physicalPath);
}
_context.ProductPhotos.Remove(photo);
await _context.SaveChangesAsync();
return true;
}
public async Task<ProductPhotoDto?> AddProductPhotoAsync(CreateProductPhotoDto photoDto)
{
var product = await _context.Products.FindAsync(photoDto.ProductId);
if (product == null) return null;
var existingPhotos = await _context.ProductPhotos
.Where(pp => pp.ProductId == photoDto.ProductId)
.ToListAsync();
var maxSortOrder = existingPhotos.Any() ? existingPhotos.Max(pp => pp.SortOrder) : 0;
var productPhoto = new ProductPhoto
{
Id = Guid.NewGuid(),
ProductId = photoDto.ProductId,
FileName = Path.GetFileName(photoDto.PhotoUrl),
FilePath = photoDto.PhotoUrl,
AltText = photoDto.AltText,
SortOrder = photoDto.DisplayOrder > 0 ? photoDto.DisplayOrder : maxSortOrder + 1,
CreatedAt = DateTime.UtcNow
};
_context.ProductPhotos.Add(productPhoto);
await _context.SaveChangesAsync();
return new ProductPhotoDto
{
Id = productPhoto.Id,
FileName = productPhoto.FileName,
FilePath = productPhoto.FilePath,
AltText = productPhoto.AltText,
SortOrder = productPhoto.SortOrder
};
}
public async Task<IEnumerable<ProductDto>> SearchProductsAsync(string searchTerm)
{
var query = _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.MultiBuys.Where(v => v.IsActive))
.Where(p => p.IsActive);
if (!string.IsNullOrWhiteSpace(searchTerm))
{
searchTerm = searchTerm.ToLower();
query = query.Where(p =>
p.Name.ToLower().Contains(searchTerm) ||
p.Description.ToLower().Contains(searchTerm));
}
return await query.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Weight = p.Weight,
WeightUnit = p.WeightUnit,
StockQuantity = p.StockQuantity,
CategoryId = p.CategoryId,
CategoryName = p.Category.Name,
CreatedAt = p.CreatedAt,
UpdatedAt = p.UpdatedAt,
IsActive = p.IsActive,
Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
{
Id = ph.Id,
FileName = ph.FileName,
FilePath = ph.FilePath,
AltText = ph.AltText,
SortOrder = ph.SortOrder
}).ToList()
}).ToListAsync();
}
// Product Multi-Buy Methods
public async Task<ProductMultiBuyDto> CreateProductMultiBuyAsync(CreateProductMultiBuyDto createMultiBuyDto)
{
var product = await _context.Products.FindAsync(createMultiBuyDto.ProductId);
if (product == null)
throw new ArgumentException("Product not found");
// Check if multi-buy with this quantity already exists
var existingMultiBuy = await _context.ProductMultiBuys
.FirstOrDefaultAsync(v => v.ProductId == createMultiBuyDto.ProductId &&
v.Quantity == createMultiBuyDto.Quantity &&
v.IsActive);
if (existingMultiBuy != null)
throw new ArgumentException($"A multi-buy with quantity {createMultiBuyDto.Quantity} already exists for this product");
var pricePerUnit = createMultiBuyDto.Price / createMultiBuyDto.Quantity;
var multiBuy = new ProductMultiBuy
{
Id = Guid.NewGuid(),
ProductId = createMultiBuyDto.ProductId,
Name = createMultiBuyDto.Name,
Description = createMultiBuyDto.Description,
Quantity = createMultiBuyDto.Quantity,
Price = createMultiBuyDto.Price,
PricePerUnit = pricePerUnit,
SortOrder = createMultiBuyDto.SortOrder,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.ProductMultiBuys.Add(multiBuy);
try
{
await _context.SaveChangesAsync();
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) when (ex.InnerException?.Message.Contains("UNIQUE constraint failed") == true)
{
throw new ArgumentException($"A multi-buy with quantity {createMultiBuyDto.Quantity} already exists for this product");
}
return new ProductMultiBuyDto
{
Id = multiBuy.Id,
ProductId = multiBuy.ProductId,
Name = multiBuy.Name,
Description = multiBuy.Description,
Quantity = multiBuy.Quantity,
Price = multiBuy.Price,
PricePerUnit = multiBuy.PricePerUnit,
SortOrder = multiBuy.SortOrder,
IsActive = multiBuy.IsActive,
CreatedAt = multiBuy.CreatedAt,
UpdatedAt = multiBuy.UpdatedAt
};
}
public async Task<bool> UpdateProductMultiBuyAsync(Guid id, UpdateProductMultiBuyDto updateMultiBuyDto)
{
var multiBuy = await _context.ProductMultiBuys.FindAsync(id);
if (multiBuy == null) return false;
if (!string.IsNullOrEmpty(updateMultiBuyDto.Name))
multiBuy.Name = updateMultiBuyDto.Name;
if (!string.IsNullOrEmpty(updateMultiBuyDto.Description))
multiBuy.Description = updateMultiBuyDto.Description;
if (updateMultiBuyDto.Quantity.HasValue)
multiBuy.Quantity = updateMultiBuyDto.Quantity.Value;
if (updateMultiBuyDto.Price.HasValue)
multiBuy.Price = updateMultiBuyDto.Price.Value;
if (updateMultiBuyDto.Quantity.HasValue || updateMultiBuyDto.Price.HasValue)
multiBuy.PricePerUnit = multiBuy.Price / multiBuy.Quantity;
if (updateMultiBuyDto.SortOrder.HasValue)
multiBuy.SortOrder = updateMultiBuyDto.SortOrder.Value;
if (updateMultiBuyDto.IsActive.HasValue)
multiBuy.IsActive = updateMultiBuyDto.IsActive.Value;
multiBuy.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> DeleteProductMultiBuyAsync(Guid id)
{
var multiBuy = await _context.ProductMultiBuys.FindAsync(id);
if (multiBuy == null) return false;
multiBuy.IsActive = false;
multiBuy.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<IEnumerable<ProductMultiBuyDto>> GetProductMultiBuysAsync(Guid productId)
{
return await _context.ProductMultiBuys
.Where(v => v.ProductId == productId && v.IsActive)
.OrderBy(v => v.SortOrder)
.Select(v => new ProductMultiBuyDto
{
Id = v.Id,
ProductId = v.ProductId,
Name = v.Name,
Description = v.Description,
Quantity = v.Quantity,
Price = v.Price,
PricePerUnit = v.PricePerUnit,
SortOrder = v.SortOrder,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
})
.ToListAsync();
}
public async Task<ProductMultiBuyDto?> GetProductMultiBuyByIdAsync(Guid id)
{
var multiBuy = await _context.ProductMultiBuys.FindAsync(id);
if (multiBuy == null) return null;
return new ProductMultiBuyDto
{
Id = multiBuy.Id,
ProductId = multiBuy.ProductId,
Name = multiBuy.Name,
Description = multiBuy.Description,
Quantity = multiBuy.Quantity,
Price = multiBuy.Price,
PricePerUnit = multiBuy.PricePerUnit,
SortOrder = multiBuy.SortOrder,
IsActive = multiBuy.IsActive,
CreatedAt = multiBuy.CreatedAt,
UpdatedAt = multiBuy.UpdatedAt
};
}
// Product Variant Methods (for color/flavor options)
public async Task<ProductVariantDto> CreateProductVariantAsync(CreateProductVariantDto createVariantDto)
{
var product = await _context.Products.FindAsync(createVariantDto.ProductId);
if (product == null)
throw new ArgumentException("Product not found");
// Check if variant with this name already exists
var existingVariant = await _context.ProductVariants
.FirstOrDefaultAsync(v => v.ProductId == createVariantDto.ProductId &&
v.Name == createVariantDto.Name &&
v.IsActive);
if (existingVariant != null)
throw new ArgumentException($"A variant named '{createVariantDto.Name}' already exists for this product");
var variant = new ProductVariant
{
Id = Guid.NewGuid(),
ProductId = createVariantDto.ProductId,
Name = createVariantDto.Name,
VariantType = createVariantDto.VariantType,
SortOrder = createVariantDto.SortOrder,
StockLevel = createVariantDto.StockLevel,
Price = createVariantDto.Price,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.ProductVariants.Add(variant);
await _context.SaveChangesAsync();
return new ProductVariantDto
{
Id = variant.Id,
ProductId = variant.ProductId,
Name = variant.Name,
VariantType = variant.VariantType,
SortOrder = variant.SortOrder,
StockLevel = variant.StockLevel,
Price = variant.Price,
IsActive = variant.IsActive,
CreatedAt = variant.CreatedAt,
UpdatedAt = variant.UpdatedAt
};
}
public async Task<bool> UpdateProductVariantAsync(Guid id, UpdateProductVariantDto updateVariantDto)
{
var variant = await _context.ProductVariants.FindAsync(id);
if (variant == null) return false;
if (!string.IsNullOrEmpty(updateVariantDto.Name))
variant.Name = updateVariantDto.Name;
if (!string.IsNullOrEmpty(updateVariantDto.VariantType))
variant.VariantType = updateVariantDto.VariantType;
if (updateVariantDto.SortOrder.HasValue)
variant.SortOrder = updateVariantDto.SortOrder.Value;
if (updateVariantDto.StockLevel.HasValue)
variant.StockLevel = updateVariantDto.StockLevel.Value;
if (updateVariantDto.Price.HasValue)
variant.Price = updateVariantDto.Price.Value;
if (updateVariantDto.IsActive.HasValue)
variant.IsActive = updateVariantDto.IsActive.Value;
variant.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> DeleteProductVariantAsync(Guid id)
{
var variant = await _context.ProductVariants.FindAsync(id);
if (variant == null) return false;
variant.IsActive = false;
variant.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<IEnumerable<ProductVariantDto>> GetProductVariantsAsync(Guid productId)
{
return await _context.ProductVariants
.Where(v => v.ProductId == productId && v.IsActive)
.OrderBy(v => v.SortOrder)
.Select(v => new ProductVariantDto
{
Id = v.Id,
ProductId = v.ProductId,
Name = v.Name,
VariantType = v.VariantType,
SortOrder = v.SortOrder,
StockLevel = v.StockLevel,
Price = v.Price,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
})
.ToListAsync();
}
public async Task<ProductVariantDto?> GetProductVariantByIdAsync(Guid id)
{
var variant = await _context.ProductVariants.FindAsync(id);
if (variant == null) return null;
return new ProductVariantDto
{
Id = variant.Id,
ProductId = variant.ProductId,
Name = variant.Name,
VariantType = variant.VariantType,
SortOrder = variant.SortOrder,
StockLevel = variant.StockLevel,
Price = variant.Price,
IsActive = variant.IsActive,
CreatedAt = variant.CreatedAt,
UpdatedAt = variant.UpdatedAt
};
}
}