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:
2025-09-21 00:30:12 +01:00
parent 7683b7dfe5
commit 034b8facee
46 changed files with 3190 additions and 332 deletions

View File

@@ -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
};
}
}