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

@@ -0,0 +1,225 @@
using System.Text.Json;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Hubs;
using LittleShop.Models;
namespace LittleShop.Services;
public class BotActivityService : IBotActivityService
{
private readonly LittleShopContext _context;
private readonly IHubContext<ActivityHub> _hubContext;
private readonly ILogger<BotActivityService> _logger;
public BotActivityService(
LittleShopContext context,
IHubContext<ActivityHub> hubContext,
ILogger<BotActivityService> logger)
{
_context = context;
_hubContext = hubContext;
_logger = logger;
}
public async Task<BotActivity> LogActivityAsync(CreateBotActivityDto dto)
{
var activity = new BotActivity
{
Id = Guid.NewGuid(),
BotId = dto.BotId,
SessionIdentifier = dto.SessionIdentifier,
UserDisplayName = dto.UserDisplayName,
ActivityType = dto.ActivityType,
ActivityDescription = dto.ActivityDescription,
ProductId = dto.ProductId,
ProductName = dto.ProductName,
OrderId = dto.OrderId,
CategoryName = dto.CategoryName,
Value = dto.Value,
Quantity = dto.Quantity,
Platform = dto.Platform,
DeviceType = dto.DeviceType,
Location = dto.Location,
Timestamp = DateTime.UtcNow,
Metadata = dto.Metadata
};
_context.BotActivities.Add(activity);
await _context.SaveChangesAsync();
// Broadcast the activity to connected clients
await BroadcastActivityAsync(activity);
_logger.LogInformation("Activity logged: {User} - {Type} - {Description}",
activity.UserDisplayName, activity.ActivityType, activity.ActivityDescription);
return activity;
}
public async Task<List<BotActivityDto>> GetRecentActivitiesAsync(int minutesBack = 5)
{
var cutoffTime = DateTime.UtcNow.AddMinutes(-minutesBack);
var activities = await _context.BotActivities
.Include(a => a.Bot)
.Where(a => a.Timestamp >= cutoffTime)
.OrderByDescending(a => a.Timestamp)
.Take(100)
.Select(a => MapToDto(a))
.ToListAsync();
return activities;
}
public async Task<List<BotActivityDto>> GetActivitiesBySessionAsync(string sessionIdentifier)
{
var activities = await _context.BotActivities
.Include(a => a.Bot)
.Where(a => a.SessionIdentifier == sessionIdentifier)
.OrderByDescending(a => a.Timestamp)
.Take(200)
.Select(a => MapToDto(a))
.ToListAsync();
return activities;
}
public async Task<List<BotActivityDto>> GetActivitiesByBotAsync(Guid botId, int limit = 100)
{
var activities = await _context.BotActivities
.Include(a => a.Bot)
.Where(a => a.BotId == botId)
.OrderByDescending(a => a.Timestamp)
.Take(limit)
.Select(a => MapToDto(a))
.ToListAsync();
return activities;
}
public async Task<LiveActivitySummaryDto> GetLiveActivitySummaryAsync()
{
var fiveMinutesAgo = DateTime.UtcNow.AddMinutes(-5);
var oneMinuteAgo = DateTime.UtcNow.AddMinutes(-1);
var recentActivities = await _context.BotActivities
.Include(a => a.Bot)
.Where(a => a.Timestamp >= fiveMinutesAgo)
.ToListAsync();
var activeUsers = recentActivities
.Where(a => a.Timestamp >= oneMinuteAgo)
.Select(a => a.SessionIdentifier)
.Distinct()
.Count();
var activeUserNames = recentActivities
.Where(a => a.Timestamp >= oneMinuteAgo)
.Select(a => a.UserDisplayName)
.Distinct()
.Take(10)
.ToList();
var productViews = recentActivities
.Where(a => a.ActivityType == "ViewProduct")
.Count();
var cartsActive = recentActivities
.Where(a => a.ActivityType == "AddToCart" || a.ActivityType == "UpdateCart")
.Select(a => a.SessionIdentifier)
.Distinct()
.Count();
var totalCartValue = recentActivities
.Where(a => a.ActivityType == "AddToCart" && a.Value.HasValue)
.Sum(a => a.Value ?? 0);
var summary = new LiveActivitySummaryDto
{
ActiveUsers = activeUsers,
TotalActivitiesLast5Min = recentActivities.Count,
ProductViewsLast5Min = productViews,
CartsActiveNow = cartsActive,
TotalValueInCartsNow = totalCartValue,
ActiveUserNames = activeUserNames,
RecentActivities = recentActivities
.OrderByDescending(a => a.Timestamp)
.Take(20)
.Select(a => MapToDto(a))
.ToList()
};
return summary;
}
public async Task<List<BotActivityDto>> GetProductActivitiesAsync(Guid productId, int limit = 50)
{
var activities = await _context.BotActivities
.Include(a => a.Bot)
.Where(a => a.ProductId == productId)
.OrderByDescending(a => a.Timestamp)
.Take(limit)
.Select(a => MapToDto(a))
.ToListAsync();
return activities;
}
public async Task<Dictionary<string, int>> GetActivityTypeStatsAsync(int hoursBack = 24)
{
var cutoffTime = DateTime.UtcNow.AddHours(-hoursBack);
var stats = await _context.BotActivities
.Where(a => a.Timestamp >= cutoffTime)
.GroupBy(a => a.ActivityType)
.Select(g => new { Type = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Type, x => x.Count);
return stats;
}
public async Task BroadcastActivityAsync(BotActivity activity)
{
try
{
var dto = MapToDto(activity);
await _hubContext.Clients.All.SendAsync("NewActivity", dto);
// Also send summary update
var summary = await GetLiveActivitySummaryAsync();
await _hubContext.Clients.All.SendAsync("SummaryUpdate", summary);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error broadcasting activity");
}
}
private static BotActivityDto MapToDto(BotActivity activity)
{
return new BotActivityDto
{
Id = activity.Id,
BotId = activity.BotId,
BotName = activity.Bot?.Name ?? "Unknown Bot",
SessionIdentifier = activity.SessionIdentifier,
UserDisplayName = activity.UserDisplayName,
ActivityType = activity.ActivityType,
ActivityDescription = activity.ActivityDescription,
ProductId = activity.ProductId,
ProductName = activity.ProductName,
OrderId = activity.OrderId,
CategoryName = activity.CategoryName,
Value = activity.Value,
Quantity = activity.Quantity,
Platform = activity.Platform,
DeviceType = activity.DeviceType,
Location = activity.Location,
Timestamp = activity.Timestamp,
Metadata = activity.Metadata
};
}
}

View File

@@ -0,0 +1,16 @@
using LittleShop.DTOs;
using LittleShop.Models;
namespace LittleShop.Services;
public interface IBotActivityService
{
Task<BotActivity> LogActivityAsync(CreateBotActivityDto dto);
Task<List<BotActivityDto>> GetRecentActivitiesAsync(int minutesBack = 5);
Task<List<BotActivityDto>> GetActivitiesBySessionAsync(string sessionIdentifier);
Task<List<BotActivityDto>> GetActivitiesByBotAsync(Guid botId, int limit = 100);
Task<LiveActivitySummaryDto> GetLiveActivitySummaryAsync();
Task<List<BotActivityDto>> GetProductActivitiesAsync(Guid productId, int limit = 50);
Task<Dictionary<string, int>> GetActivityTypeStatsAsync(int hoursBack = 24);
Task BroadcastActivityAsync(BotActivity activity);
}

View File

@@ -15,10 +15,17 @@ public interface IProductService
Task<bool> RemoveProductPhotoAsync(Guid productId, Guid photoId);
Task<IEnumerable<ProductDto>> SearchProductsAsync(string searchTerm);
// Product Variations
Task<ProductVariationDto> CreateProductVariationAsync(CreateProductVariationDto createVariationDto);
Task<bool> UpdateProductVariationAsync(Guid id, UpdateProductVariationDto updateVariationDto);
Task<bool> DeleteProductVariationAsync(Guid id);
Task<IEnumerable<ProductVariationDto>> GetProductVariationsAsync(Guid productId);
Task<ProductVariationDto?> GetProductVariationByIdAsync(Guid id);
// Product Multi-Buys
Task<ProductMultiBuyDto> CreateProductMultiBuyAsync(CreateProductMultiBuyDto createMultiBuyDto);
Task<bool> UpdateProductMultiBuyAsync(Guid id, UpdateProductMultiBuyDto updateMultiBuyDto);
Task<bool> DeleteProductMultiBuyAsync(Guid id);
Task<IEnumerable<ProductMultiBuyDto>> GetProductMultiBuysAsync(Guid productId);
Task<ProductMultiBuyDto?> GetProductMultiBuyByIdAsync(Guid id);
// Product Variants
Task<ProductVariantDto> CreateProductVariantAsync(CreateProductVariantDto createVariantDto);
Task<bool> UpdateProductVariantAsync(Guid id, UpdateProductVariantDto updateVariantDto);
Task<bool> DeleteProductVariantAsync(Guid id);
Task<IEnumerable<ProductVariantDto>> GetProductVariantsAsync(Guid productId);
Task<ProductVariantDto?> GetProductVariantByIdAsync(Guid id);
}

View File

@@ -30,7 +30,7 @@ public class OrderService : IOrderService
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariation)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
@@ -45,7 +45,7 @@ public class OrderService : IOrderService
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariation)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.Where(o => o.IdentityReference == identityReference)
.OrderByDescending(o => o.CreatedAt)
@@ -61,7 +61,7 @@ public class OrderService : IOrderService
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariation)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
@@ -77,7 +77,7 @@ public class OrderService : IOrderService
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariation)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == id);
@@ -146,20 +146,20 @@ public class OrderService : IOrderService
throw new ArgumentException($"Product {itemDto.ProductId} not found or inactive");
}
ProductVariation? variation = null;
ProductMultiBuy? multiBuy = null;
decimal unitPrice = product.Price;
if (itemDto.ProductVariationId.HasValue)
if (itemDto.ProductMultiBuyId.HasValue)
{
variation = await _context.ProductVariations.FindAsync(itemDto.ProductVariationId.Value);
if (variation == null || !variation.IsActive || variation.ProductId != itemDto.ProductId)
multiBuy = await _context.ProductMultiBuys.FindAsync(itemDto.ProductMultiBuyId.Value);
if (multiBuy == null || !multiBuy.IsActive || multiBuy.ProductId != itemDto.ProductId)
{
throw new ArgumentException($"Product variation {itemDto.ProductVariationId} not found, inactive, or doesn't belong to product {itemDto.ProductId}");
throw new ArgumentException($"Product multi-buy {itemDto.ProductMultiBuyId} not found, inactive, or doesn't belong to product {itemDto.ProductId}");
}
// When using a variation, the quantity represents how many of that variation bundle
// For example: buying 2 of the "3 for £25" variation means 6 total items for £50
unitPrice = variation.Price;
// When using a multi-buy, the quantity represents how many of that multi-buy bundle
// For example: buying 2 of the "3 for £25" multi-buy means 6 total items for £50
unitPrice = multiBuy.Price;
}
var orderItem = new OrderItem
@@ -167,7 +167,7 @@ public class OrderService : IOrderService
Id = Guid.NewGuid(),
OrderId = order.Id,
ProductId = itemDto.ProductId,
ProductVariationId = itemDto.ProductVariationId,
ProductMultiBuyId = itemDto.ProductMultiBuyId,
Quantity = itemDto.Quantity,
UnitPrice = unitPrice,
TotalPrice = unitPrice * itemDto.Quantity
@@ -321,9 +321,9 @@ public class OrderService : IOrderService
{
Id = oi.Id,
ProductId = oi.ProductId,
ProductVariationId = oi.ProductVariationId,
ProductMultiBuyId = oi.ProductMultiBuyId,
ProductName = oi.Product.Name,
ProductVariationName = oi.ProductVariation?.Name,
ProductMultiBuyName = oi.ProductMultiBuy?.Name,
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice,
TotalPrice = oi.TotalPrice
@@ -500,7 +500,7 @@ public class OrderService : IOrderService
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariation)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.Where(o => o.Status == status)
.OrderByDescending(o => o.CreatedAt)

View File

@@ -183,7 +183,7 @@ public class ProductImportService : IProductImportService
// Import variations if provided
if (!string.IsNullOrEmpty(importDto.Variations))
{
await ImportProductVariationsAsync(product.Id, importDto.Variations);
await ImportProductMultiBuysAsync(product.Id, importDto.Variations);
}
// Import photos if provided
@@ -206,7 +206,7 @@ public class ProductImportService : IProductImportService
}
}
private async Task ImportProductVariationsAsync(Guid productId, string variationsText)
private async Task ImportProductMultiBuysAsync(Guid productId, string variationsText)
{
// Format: "Single Item:1:10.00;Twin Pack:2:19.00;Triple Pack:3:25.00"
var variations = variationsText.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -216,7 +216,7 @@ public class ProductImportService : IProductImportService
var parts = variations[i].Split(':', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 3)
{
var variationDto = new CreateProductVariationDto
var multiBuyDto = new CreateProductMultiBuyDto
{
ProductId = productId,
Name = parts[0].Trim(),
@@ -226,7 +226,7 @@ public class ProductImportService : IProductImportService
SortOrder = i
};
await _productService.CreateProductVariationAsync(variationDto);
await _productService.CreateProductMultiBuyAsync(multiBuyDto);
}
}
}
@@ -275,7 +275,7 @@ public class ProductImportService : IProductImportService
foreach (var product in products)
{
// Build variations string
var variationsText = string.Join(";", product.Variations.OrderBy(v => v.SortOrder)
var variationsText = string.Join(";", product.MultiBuys.OrderBy(v => v.SortOrder)
.Select(v => $"{v.Name}:{v.Quantity}:{v.Price:F2}"));
// Build photo URLs string

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