Implement product multi-buys and variants system
Major restructuring of product variations: - Renamed ProductVariation to ProductMultiBuy for quantity-based pricing (e.g., "3 for £25") - Added new ProductVariant model for string-based options (colors, flavors) - Complete separation of multi-buy pricing from variant selection Features implemented: - Multi-buy deals with automatic price-per-unit calculation - Product variants for colors/flavors/sizes with stock tracking - TeleBot checkout supports both multi-buys and variant selection - Shopping cart correctly calculates multi-buy bundle prices - Order system tracks selected variants and multi-buy choices - Real-time bot activity monitoring with SignalR - Public bot directory page with QR codes for Telegram launch - Admin dashboard shows multi-buy and variant metrics Technical changes: - Updated all DTOs, services, and controllers - Fixed cart total calculation for multi-buy bundles - Comprehensive test coverage for new functionality - All existing tests passing with new features Database changes: - Migrated ProductVariations to ProductMultiBuys - Added ProductVariants table - Updated OrderItems to track variants 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ public class ProductService : IProductService
|
||||
return await _context.Products
|
||||
.Include(p => p.Category)
|
||||
.Include(p => p.Photos)
|
||||
.Include(p => p.Variations.Where(v => v.IsActive))
|
||||
.Include(p => p.MultiBuys.Where(v => v.IsActive))
|
||||
.Where(p => p.IsActive)
|
||||
.Select(p => new ProductDto
|
||||
{
|
||||
@@ -45,7 +45,7 @@ public class ProductService : IProductService
|
||||
AltText = ph.AltText,
|
||||
SortOrder = ph.SortOrder
|
||||
}).ToList(),
|
||||
Variations = p.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
|
||||
MultiBuys = p.MultiBuys.OrderBy(v => v.SortOrder).Select(v => new ProductMultiBuyDto
|
||||
{
|
||||
Id = v.Id,
|
||||
ProductId = v.ProductId,
|
||||
@@ -68,7 +68,7 @@ public class ProductService : IProductService
|
||||
return await _context.Products
|
||||
.Include(p => p.Category)
|
||||
.Include(p => p.Photos)
|
||||
.Include(p => p.Variations.Where(v => v.IsActive))
|
||||
.Include(p => p.MultiBuys.Where(v => v.IsActive))
|
||||
.Where(p => p.IsActive && p.CategoryId == categoryId)
|
||||
.Select(p => new ProductDto
|
||||
{
|
||||
@@ -92,7 +92,7 @@ public class ProductService : IProductService
|
||||
AltText = ph.AltText,
|
||||
SortOrder = ph.SortOrder
|
||||
}).ToList(),
|
||||
Variations = p.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
|
||||
MultiBuys = p.MultiBuys.OrderBy(v => v.SortOrder).Select(v => new ProductMultiBuyDto
|
||||
{
|
||||
Id = v.Id,
|
||||
ProductId = v.ProductId,
|
||||
@@ -115,7 +115,7 @@ public class ProductService : IProductService
|
||||
var product = await _context.Products
|
||||
.Include(p => p.Category)
|
||||
.Include(p => p.Photos)
|
||||
.Include(p => p.Variations.Where(v => v.IsActive))
|
||||
.Include(p => p.MultiBuys.Where(v => v.IsActive))
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (product == null) return null;
|
||||
@@ -142,7 +142,7 @@ public class ProductService : IProductService
|
||||
AltText = ph.AltText,
|
||||
SortOrder = ph.SortOrder
|
||||
}).ToList(),
|
||||
Variations = product.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
|
||||
MultiBuys = product.MultiBuys.OrderBy(v => v.SortOrder).Select(v => new ProductMultiBuyDto
|
||||
{
|
||||
Id = v.Id,
|
||||
ProductId = v.ProductId,
|
||||
@@ -195,7 +195,8 @@ public class ProductService : IProductService
|
||||
UpdatedAt = product.UpdatedAt,
|
||||
IsActive = product.IsActive,
|
||||
Photos = new List<ProductPhotoDto>(),
|
||||
Variations = new List<ProductVariationDto>()
|
||||
MultiBuys = new List<ProductMultiBuyDto>(),
|
||||
Variants = new List<ProductVariantDto>()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,7 +340,7 @@ public class ProductService : IProductService
|
||||
var query = _context.Products
|
||||
.Include(p => p.Category)
|
||||
.Include(p => p.Photos)
|
||||
.Include(p => p.Variations.Where(v => v.IsActive))
|
||||
.Include(p => p.MultiBuys.Where(v => v.IsActive))
|
||||
.Where(p => p.IsActive);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
@@ -375,39 +376,40 @@ public class ProductService : IProductService
|
||||
}).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ProductVariationDto> CreateProductVariationAsync(CreateProductVariationDto createVariationDto)
|
||||
// Product Multi-Buy Methods
|
||||
public async Task<ProductMultiBuyDto> CreateProductMultiBuyAsync(CreateProductMultiBuyDto createMultiBuyDto)
|
||||
{
|
||||
var product = await _context.Products.FindAsync(createVariationDto.ProductId);
|
||||
var product = await _context.Products.FindAsync(createMultiBuyDto.ProductId);
|
||||
if (product == null)
|
||||
throw new ArgumentException("Product not found");
|
||||
|
||||
// Check if variation with this quantity already exists
|
||||
var existingVariation = await _context.ProductVariations
|
||||
.FirstOrDefaultAsync(v => v.ProductId == createVariationDto.ProductId &&
|
||||
v.Quantity == createVariationDto.Quantity &&
|
||||
// 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 (existingVariation != null)
|
||||
throw new ArgumentException($"A variation with quantity {createVariationDto.Quantity} already exists for this product");
|
||||
if (existingMultiBuy != null)
|
||||
throw new ArgumentException($"A multi-buy with quantity {createMultiBuyDto.Quantity} already exists for this product");
|
||||
|
||||
var pricePerUnit = createVariationDto.Price / createVariationDto.Quantity;
|
||||
var pricePerUnit = createMultiBuyDto.Price / createMultiBuyDto.Quantity;
|
||||
|
||||
var variation = new ProductVariation
|
||||
var multiBuy = new ProductMultiBuy
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProductId = createVariationDto.ProductId,
|
||||
Name = createVariationDto.Name,
|
||||
Description = createVariationDto.Description,
|
||||
Quantity = createVariationDto.Quantity,
|
||||
Price = createVariationDto.Price,
|
||||
ProductId = createMultiBuyDto.ProductId,
|
||||
Name = createMultiBuyDto.Name,
|
||||
Description = createMultiBuyDto.Description,
|
||||
Quantity = createMultiBuyDto.Quantity,
|
||||
Price = createMultiBuyDto.Price,
|
||||
PricePerUnit = pricePerUnit,
|
||||
SortOrder = createVariationDto.SortOrder,
|
||||
SortOrder = createMultiBuyDto.SortOrder,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.ProductVariations.Add(variation);
|
||||
_context.ProductMultiBuys.Add(multiBuy);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -415,74 +417,74 @@ public class ProductService : IProductService
|
||||
}
|
||||
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) when (ex.InnerException?.Message.Contains("UNIQUE constraint failed") == true)
|
||||
{
|
||||
throw new ArgumentException($"A variation with quantity {createVariationDto.Quantity} already exists for this product");
|
||||
throw new ArgumentException($"A multi-buy with quantity {createMultiBuyDto.Quantity} already exists for this product");
|
||||
}
|
||||
|
||||
return new ProductVariationDto
|
||||
return new ProductMultiBuyDto
|
||||
{
|
||||
Id = variation.Id,
|
||||
ProductId = variation.ProductId,
|
||||
Name = variation.Name,
|
||||
Description = variation.Description,
|
||||
Quantity = variation.Quantity,
|
||||
Price = variation.Price,
|
||||
PricePerUnit = variation.PricePerUnit,
|
||||
SortOrder = variation.SortOrder,
|
||||
IsActive = variation.IsActive,
|
||||
CreatedAt = variation.CreatedAt,
|
||||
UpdatedAt = variation.UpdatedAt
|
||||
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> UpdateProductVariationAsync(Guid id, UpdateProductVariationDto updateVariationDto)
|
||||
public async Task<bool> UpdateProductMultiBuyAsync(Guid id, UpdateProductMultiBuyDto updateMultiBuyDto)
|
||||
{
|
||||
var variation = await _context.ProductVariations.FindAsync(id);
|
||||
if (variation == null) return false;
|
||||
var multiBuy = await _context.ProductMultiBuys.FindAsync(id);
|
||||
if (multiBuy == null) return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(updateVariationDto.Name))
|
||||
variation.Name = updateVariationDto.Name;
|
||||
if (!string.IsNullOrEmpty(updateMultiBuyDto.Name))
|
||||
multiBuy.Name = updateMultiBuyDto.Name;
|
||||
|
||||
if (!string.IsNullOrEmpty(updateVariationDto.Description))
|
||||
variation.Description = updateVariationDto.Description;
|
||||
if (!string.IsNullOrEmpty(updateMultiBuyDto.Description))
|
||||
multiBuy.Description = updateMultiBuyDto.Description;
|
||||
|
||||
if (updateVariationDto.Quantity.HasValue)
|
||||
variation.Quantity = updateVariationDto.Quantity.Value;
|
||||
if (updateMultiBuyDto.Quantity.HasValue)
|
||||
multiBuy.Quantity = updateMultiBuyDto.Quantity.Value;
|
||||
|
||||
if (updateVariationDto.Price.HasValue)
|
||||
variation.Price = updateVariationDto.Price.Value;
|
||||
if (updateMultiBuyDto.Price.HasValue)
|
||||
multiBuy.Price = updateMultiBuyDto.Price.Value;
|
||||
|
||||
if (updateVariationDto.Quantity.HasValue || updateVariationDto.Price.HasValue)
|
||||
variation.PricePerUnit = variation.Price / variation.Quantity;
|
||||
if (updateMultiBuyDto.Quantity.HasValue || updateMultiBuyDto.Price.HasValue)
|
||||
multiBuy.PricePerUnit = multiBuy.Price / multiBuy.Quantity;
|
||||
|
||||
if (updateVariationDto.SortOrder.HasValue)
|
||||
variation.SortOrder = updateVariationDto.SortOrder.Value;
|
||||
if (updateMultiBuyDto.SortOrder.HasValue)
|
||||
multiBuy.SortOrder = updateMultiBuyDto.SortOrder.Value;
|
||||
|
||||
if (updateVariationDto.IsActive.HasValue)
|
||||
variation.IsActive = updateVariationDto.IsActive.Value;
|
||||
if (updateMultiBuyDto.IsActive.HasValue)
|
||||
multiBuy.IsActive = updateMultiBuyDto.IsActive.Value;
|
||||
|
||||
variation.UpdatedAt = DateTime.UtcNow;
|
||||
multiBuy.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteProductVariationAsync(Guid id)
|
||||
public async Task<bool> DeleteProductMultiBuyAsync(Guid id)
|
||||
{
|
||||
var variation = await _context.ProductVariations.FindAsync(id);
|
||||
if (variation == null) return false;
|
||||
var multiBuy = await _context.ProductMultiBuys.FindAsync(id);
|
||||
if (multiBuy == null) return false;
|
||||
|
||||
variation.IsActive = false;
|
||||
variation.UpdatedAt = DateTime.UtcNow;
|
||||
multiBuy.IsActive = false;
|
||||
multiBuy.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ProductVariationDto>> GetProductVariationsAsync(Guid productId)
|
||||
public async Task<IEnumerable<ProductMultiBuyDto>> GetProductMultiBuysAsync(Guid productId)
|
||||
{
|
||||
return await _context.ProductVariations
|
||||
return await _context.ProductMultiBuys
|
||||
.Where(v => v.ProductId == productId && v.IsActive)
|
||||
.OrderBy(v => v.SortOrder)
|
||||
.Select(v => new ProductVariationDto
|
||||
.Select(v => new ProductMultiBuyDto
|
||||
{
|
||||
Id = v.Id,
|
||||
ProductId = v.ProductId,
|
||||
@@ -499,24 +501,145 @@ public class ProductService : IProductService
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ProductVariationDto?> GetProductVariationByIdAsync(Guid id)
|
||||
public async Task<ProductMultiBuyDto?> GetProductMultiBuyByIdAsync(Guid id)
|
||||
{
|
||||
var variation = await _context.ProductVariations.FindAsync(id);
|
||||
if (variation == null) return null;
|
||||
var multiBuy = await _context.ProductMultiBuys.FindAsync(id);
|
||||
if (multiBuy == null) return null;
|
||||
|
||||
return new ProductVariationDto
|
||||
return new ProductMultiBuyDto
|
||||
{
|
||||
Id = variation.Id,
|
||||
ProductId = variation.ProductId,
|
||||
Name = variation.Name,
|
||||
Description = variation.Description,
|
||||
Quantity = variation.Quantity,
|
||||
Price = variation.Price,
|
||||
PricePerUnit = variation.PricePerUnit,
|
||||
SortOrder = variation.SortOrder,
|
||||
IsActive = variation.IsActive,
|
||||
CreatedAt = variation.CreatedAt,
|
||||
UpdatedAt = variation.UpdatedAt
|
||||
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,
|
||||
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,
|
||||
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.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,
|
||||
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,
|
||||
IsActive = variant.IsActive,
|
||||
CreatedAt = variant.CreatedAt,
|
||||
UpdatedAt = variant.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user