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:
225
LittleShop/Services/BotActivityService.cs
Normal file
225
LittleShop/Services/BotActivityService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
16
LittleShop/Services/IBotActivityService.cs
Normal file
16
LittleShop/Services/IBotActivityService.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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