Implement product variations, enhanced order workflow, mobile responsiveness, and product import system
## Product Variations System - Add ProductVariation model with quantity-based pricing (1 for £10, 2 for £19, 3 for £25) - Complete CRUD operations for product variations - Enhanced ProductService to include variations in all queries - Updated OrderItem to support ProductVariationId for variation-based orders - Graceful error handling for duplicate quantity constraints - Admin interface with variations management (Create/Edit/Delete) - API endpoints for programmatic variation management ## Enhanced Order Workflow Management - Redesigned OrderStatus enum with clear workflow states (Accept → Packing → Dispatched → Delivered) - Added workflow tracking fields (AcceptedAt, PackingStartedAt, DispatchedAt, ExpectedDeliveryDate) - User tracking for accountability (AcceptedByUser, PackedByUser, DispatchedByUser) - Automatic delivery date calculation (dispatch date + working days, skips weekends) - On Hold workflow for problem resolution with reason tracking - Tab-based orders interface focused on workflow stages - One-click workflow actions from list view ## Mobile-Responsive Design - Responsive orders interface: tables on desktop, cards on mobile - Touch-friendly buttons and spacing for mobile users - Horizontal scrolling tabs with condensed labels on mobile - Color-coded status borders for quick visual recognition - Smart text switching based on screen size ## Product Import/Export System - CSV import with product variations support - Template download with examples - Export existing products to CSV - Detailed import results with success/error reporting - Category name resolution (no need for GUIDs) - Photo URLs import support ## Enhanced Dashboard - Product variations count and metrics - Stock alerts (low stock/out of stock warnings) - Order workflow breakdown (pending, accepted, dispatched counts) - Enhanced layout with more detailed information ## Technical Improvements - Fixed form binding issues across all admin forms - Removed external CDN dependencies for isolated deployment - Bot Wizard form with auto-personality assignment - Proper authentication scheme configuration (Cookie + JWT) - Enhanced debug logging for troubleshooting ## Self-Contained Deployment - All external CDN references replaced with local libraries - Ready for air-gapped/isolated network deployment - No external internet dependencies 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Enums;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
@@ -11,4 +12,18 @@ public interface IOrderService
|
||||
Task<OrderDto> CreateOrderAsync(CreateOrderDto createOrderDto);
|
||||
Task<bool> UpdateOrderStatusAsync(Guid id, UpdateOrderStatusDto updateOrderStatusDto);
|
||||
Task<bool> CancelOrderAsync(Guid id, string identityReference);
|
||||
|
||||
// Enhanced workflow methods
|
||||
Task<bool> AcceptOrderAsync(Guid id, string userName, AcceptOrderDto acceptDto);
|
||||
Task<bool> StartPackingAsync(Guid id, string userName, StartPackingDto packingDto);
|
||||
Task<bool> DispatchOrderAsync(Guid id, string userName, DispatchOrderDto dispatchDto);
|
||||
Task<bool> PutOnHoldAsync(Guid id, string userName, PutOnHoldDto holdDto);
|
||||
Task<bool> RemoveFromHoldAsync(Guid id, string userName);
|
||||
Task<bool> MarkDeliveredAsync(Guid id, MarkDeliveredDto deliveredDto);
|
||||
|
||||
// Workflow queries
|
||||
Task<IEnumerable<OrderDto>> GetOrdersByStatusAsync(OrderStatus status);
|
||||
Task<IEnumerable<OrderDto>> GetOrdersRequiringActionAsync(); // PaymentReceived orders needing acceptance
|
||||
Task<IEnumerable<OrderDto>> GetOrdersForPackingAsync(); // Accepted orders ready for packing
|
||||
Task<IEnumerable<OrderDto>> GetOrdersOnHoldAsync(); // Orders on hold
|
||||
}
|
||||
@@ -14,4 +14,11 @@ public interface IProductService
|
||||
Task<ProductPhotoDto?> AddProductPhotoAsync(CreateProductPhotoDto photoDto);
|
||||
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);
|
||||
}
|
||||
@@ -25,6 +25,8 @@ public class OrderService : IOrderService
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariation)
|
||||
.Include(o => o.Payments)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.ToListAsync();
|
||||
@@ -38,6 +40,8 @@ public class OrderService : IOrderService
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariation)
|
||||
.Include(o => o.Payments)
|
||||
.Where(o => o.IdentityReference == identityReference)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
@@ -52,6 +56,8 @@ public class OrderService : IOrderService
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariation)
|
||||
.Include(o => o.Payments)
|
||||
.Where(o => o.CustomerId == customerId)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
@@ -66,6 +72,8 @@ public class OrderService : IOrderService
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariation)
|
||||
.Include(o => o.Payments)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
@@ -134,14 +142,31 @@ public class OrderService : IOrderService
|
||||
throw new ArgumentException($"Product {itemDto.ProductId} not found or inactive");
|
||||
}
|
||||
|
||||
ProductVariation? variation = null;
|
||||
decimal unitPrice = product.Price;
|
||||
|
||||
if (itemDto.ProductVariationId.HasValue)
|
||||
{
|
||||
variation = await _context.ProductVariations.FindAsync(itemDto.ProductVariationId.Value);
|
||||
if (variation == null || !variation.IsActive || variation.ProductId != itemDto.ProductId)
|
||||
{
|
||||
throw new ArgumentException($"Product variation {itemDto.ProductVariationId} 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;
|
||||
}
|
||||
|
||||
var orderItem = new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = order.Id,
|
||||
ProductId = itemDto.ProductId,
|
||||
ProductVariationId = itemDto.ProductVariationId,
|
||||
Quantity = itemDto.Quantity,
|
||||
UnitPrice = product.Price,
|
||||
TotalPrice = product.Price * itemDto.Quantity
|
||||
UnitPrice = unitPrice,
|
||||
TotalPrice = unitPrice * itemDto.Quantity
|
||||
};
|
||||
|
||||
_context.OrderItems.Add(orderItem);
|
||||
@@ -262,12 +287,30 @@ public class OrderService : IOrderService
|
||||
CreatedAt = order.CreatedAt,
|
||||
UpdatedAt = order.UpdatedAt,
|
||||
PaidAt = order.PaidAt,
|
||||
|
||||
// Workflow timestamps
|
||||
AcceptedAt = order.AcceptedAt,
|
||||
PackingStartedAt = order.PackingStartedAt,
|
||||
DispatchedAt = order.DispatchedAt,
|
||||
ExpectedDeliveryDate = order.ExpectedDeliveryDate,
|
||||
ActualDeliveryDate = order.ActualDeliveryDate,
|
||||
OnHoldAt = order.OnHoldAt,
|
||||
|
||||
// Workflow details
|
||||
AcceptedByUser = order.AcceptedByUser,
|
||||
PackedByUser = order.PackedByUser,
|
||||
DispatchedByUser = order.DispatchedByUser,
|
||||
OnHoldReason = order.OnHoldReason,
|
||||
|
||||
// Legacy field (for backward compatibility)
|
||||
ShippedAt = order.ShippedAt,
|
||||
Items = order.Items.Select(oi => new OrderItemDto
|
||||
{
|
||||
Id = oi.Id,
|
||||
ProductId = oi.ProductId,
|
||||
ProductVariationId = oi.ProductVariationId,
|
||||
ProductName = oi.Product.Name,
|
||||
ProductVariationName = oi.ProductVariation?.Name,
|
||||
Quantity = oi.Quantity,
|
||||
UnitPrice = oi.UnitPrice,
|
||||
TotalPrice = oi.TotalPrice
|
||||
@@ -289,4 +332,162 @@ public class OrderService : IOrderService
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
// Enhanced workflow methods
|
||||
public async Task<bool> AcceptOrderAsync(Guid id, string userName, AcceptOrderDto acceptDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status != OrderStatus.PaymentReceived)
|
||||
return false;
|
||||
|
||||
order.Status = OrderStatus.Accepted;
|
||||
order.AcceptedAt = DateTime.UtcNow;
|
||||
order.AcceptedByUser = userName;
|
||||
if (!string.IsNullOrEmpty(acceptDto.Notes))
|
||||
order.Notes = acceptDto.Notes;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} accepted by {User}", id, userName);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> StartPackingAsync(Guid id, string userName, StartPackingDto packingDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status != OrderStatus.Accepted)
|
||||
return false;
|
||||
|
||||
order.Status = OrderStatus.Packing;
|
||||
order.PackingStartedAt = DateTime.UtcNow;
|
||||
order.PackedByUser = userName;
|
||||
if (!string.IsNullOrEmpty(packingDto.Notes))
|
||||
order.Notes = packingDto.Notes;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} packing started by {User}", id, userName);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DispatchOrderAsync(Guid id, string userName, DispatchOrderDto dispatchDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status != OrderStatus.Packing)
|
||||
return false;
|
||||
|
||||
order.Status = OrderStatus.Dispatched;
|
||||
order.DispatchedAt = DateTime.UtcNow;
|
||||
order.DispatchedByUser = userName;
|
||||
order.TrackingNumber = dispatchDto.TrackingNumber;
|
||||
|
||||
// Calculate expected delivery date (working days only)
|
||||
var expectedDate = DateTime.UtcNow.AddDays(dispatchDto.EstimatedDeliveryDays);
|
||||
while (expectedDate.DayOfWeek == DayOfWeek.Saturday || expectedDate.DayOfWeek == DayOfWeek.Sunday)
|
||||
{
|
||||
expectedDate = expectedDate.AddDays(1);
|
||||
}
|
||||
order.ExpectedDeliveryDate = expectedDate;
|
||||
|
||||
if (!string.IsNullOrEmpty(dispatchDto.Notes))
|
||||
order.Notes = dispatchDto.Notes;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Set legacy field for backward compatibility
|
||||
order.ShippedAt = order.DispatchedAt;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} dispatched by {User} with tracking {TrackingNumber}", id, userName, dispatchDto.TrackingNumber);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> PutOnHoldAsync(Guid id, string userName, PutOnHoldDto holdDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status == OrderStatus.OnHold)
|
||||
return false;
|
||||
|
||||
order.Status = OrderStatus.OnHold;
|
||||
order.OnHoldAt = DateTime.UtcNow;
|
||||
order.OnHoldReason = holdDto.Reason;
|
||||
if (!string.IsNullOrEmpty(holdDto.Notes))
|
||||
order.Notes = holdDto.Notes;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} put on hold by {User}: {Reason}", id, userName, holdDto.Reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveFromHoldAsync(Guid id, string userName)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status != OrderStatus.OnHold)
|
||||
return false;
|
||||
|
||||
// Return to appropriate status based on workflow progress
|
||||
if (order.AcceptedAt.HasValue && order.PackingStartedAt.HasValue)
|
||||
order.Status = OrderStatus.Packing;
|
||||
else if (order.AcceptedAt.HasValue)
|
||||
order.Status = OrderStatus.Accepted;
|
||||
else
|
||||
order.Status = OrderStatus.PaymentReceived;
|
||||
|
||||
order.OnHoldAt = null;
|
||||
order.OnHoldReason = null;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} removed from hold by {User}, returned to {Status}", id, userName, order.Status);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> MarkDeliveredAsync(Guid id, MarkDeliveredDto deliveredDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status != OrderStatus.Dispatched)
|
||||
return false;
|
||||
|
||||
order.Status = OrderStatus.Delivered;
|
||||
order.ActualDeliveryDate = deliveredDto.ActualDeliveryDate ?? DateTime.UtcNow;
|
||||
if (!string.IsNullOrEmpty(deliveredDto.Notes))
|
||||
order.Notes = deliveredDto.Notes;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} marked as delivered", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Workflow queries
|
||||
public async Task<IEnumerable<OrderDto>> GetOrdersByStatusAsync(OrderStatus status)
|
||||
{
|
||||
var orders = await _context.Orders
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariation)
|
||||
.Include(o => o.Payments)
|
||||
.Where(o => o.Status == status)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return orders.Select(MapToDto);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<OrderDto>> GetOrdersRequiringActionAsync()
|
||||
{
|
||||
return await GetOrdersByStatusAsync(OrderStatus.PaymentReceived);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<OrderDto>> GetOrdersForPackingAsync()
|
||||
{
|
||||
return await GetOrdersByStatusAsync(OrderStatus.Accepted);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<OrderDto>> GetOrdersOnHoldAsync()
|
||||
{
|
||||
return await GetOrdersByStatusAsync(OrderStatus.OnHold);
|
||||
}
|
||||
}
|
||||
330
LittleShop/Services/ProductImportService.cs
Normal file
330
LittleShop/Services/ProductImportService.cs
Normal file
@@ -0,0 +1,330 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using LittleShop.Data;
|
||||
using LittleShop.Models;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Enums;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
public interface IProductImportService
|
||||
{
|
||||
Task<ProductImportResultDto> ImportFromCsvAsync(Stream csvStream);
|
||||
Task<ProductImportResultDto> ImportFromTextAsync(string csvText);
|
||||
string GenerateTemplateAsCsv();
|
||||
Task<string> ExportProductsAsCsvAsync();
|
||||
}
|
||||
|
||||
public class ProductImportService : IProductImportService
|
||||
{
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly IProductService _productService;
|
||||
private readonly ICategoryService _categoryService;
|
||||
private readonly ILogger<ProductImportService> _logger;
|
||||
|
||||
public ProductImportService(
|
||||
LittleShopContext context,
|
||||
IProductService productService,
|
||||
ICategoryService categoryService,
|
||||
ILogger<ProductImportService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_productService = productService;
|
||||
_categoryService = categoryService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ProductImportResultDto> ImportFromCsvAsync(Stream csvStream)
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
var csvText = await reader.ReadToEndAsync();
|
||||
return await ImportFromTextAsync(csvText);
|
||||
}
|
||||
|
||||
public async Task<ProductImportResultDto> ImportFromTextAsync(string csvText)
|
||||
{
|
||||
var result = new ProductImportResultDto();
|
||||
var lines = csvText.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (lines.Length == 0)
|
||||
{
|
||||
result.Errors.Add(new ProductImportErrorDto
|
||||
{
|
||||
RowNumber = 0,
|
||||
ProductName = "File",
|
||||
ErrorMessages = { "CSV file is empty" }
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Parse header
|
||||
var headers = ParseCsvLine(lines[0]);
|
||||
var expectedHeaders = new[] { "Name", "Description", "Price", "Weight", "WeightUnit", "StockQuantity", "CategoryName", "IsActive", "Variations", "PhotoUrls" };
|
||||
|
||||
// Validate headers
|
||||
foreach (var expectedHeader in expectedHeaders.Take(7)) // First 7 are required
|
||||
{
|
||||
if (!headers.Contains(expectedHeader, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Errors.Add(new ProductImportErrorDto
|
||||
{
|
||||
RowNumber = 0,
|
||||
ProductName = "Header",
|
||||
ErrorMessages = { $"Missing required column: {expectedHeader}" }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Errors.Any())
|
||||
return result;
|
||||
|
||||
result.TotalRows = lines.Length - 1; // Exclude header
|
||||
|
||||
// Get categories for lookup
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
var categoryLookup = categories.ToDictionary(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Process data rows
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var values = ParseCsvLine(lines[i]);
|
||||
if (values.Length < 7) // Minimum required columns
|
||||
{
|
||||
result.Errors.Add(new ProductImportErrorDto
|
||||
{
|
||||
RowNumber = i + 1,
|
||||
ProductName = values.Length > 0 ? values[0] : "Unknown",
|
||||
ErrorMessages = { "Insufficient columns in row" }
|
||||
});
|
||||
result.FailedImports++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var importDto = new ProductImportDto
|
||||
{
|
||||
Name = GetValue(values, headers, "Name", ""),
|
||||
Description = GetValue(values, headers, "Description", ""),
|
||||
Price = decimal.Parse(GetValue(values, headers, "Price", "0"), CultureInfo.InvariantCulture),
|
||||
Weight = decimal.Parse(GetValue(values, headers, "Weight", "0"), CultureInfo.InvariantCulture),
|
||||
WeightUnit = GetValue(values, headers, "WeightUnit", "Grams"),
|
||||
StockQuantity = int.Parse(GetValue(values, headers, "StockQuantity", "0")),
|
||||
CategoryName = GetValue(values, headers, "CategoryName", ""),
|
||||
IsActive = bool.Parse(GetValue(values, headers, "IsActive", "true")),
|
||||
Variations = GetValue(values, headers, "Variations", null),
|
||||
PhotoUrls = GetValue(values, headers, "PhotoUrls", null)
|
||||
};
|
||||
|
||||
await ImportSingleProductAsync(importDto, categoryLookup, result, i + 1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add(new ProductImportErrorDto
|
||||
{
|
||||
RowNumber = i + 1,
|
||||
ProductName = "Parse Error",
|
||||
ErrorMessages = { ex.Message }
|
||||
});
|
||||
result.FailedImports++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Product import completed: {Success} successful, {Failed} failed", result.SuccessfulImports, result.FailedImports);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task ImportSingleProductAsync(ProductImportDto importDto, Dictionary<string, Guid> categoryLookup, ProductImportResultDto result, int rowNumber)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// Validate category
|
||||
if (!categoryLookup.TryGetValue(importDto.CategoryName, out var categoryId))
|
||||
{
|
||||
errors.Add($"Category '{importDto.CategoryName}' not found");
|
||||
}
|
||||
|
||||
// Validate weight unit
|
||||
if (!Enum.TryParse<ProductWeightUnit>(importDto.WeightUnit, true, out var weightUnit))
|
||||
{
|
||||
errors.Add($"Invalid weight unit: {importDto.WeightUnit}");
|
||||
}
|
||||
|
||||
if (errors.Any())
|
||||
{
|
||||
result.Errors.Add(new ProductImportErrorDto
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
ProductName = importDto.Name,
|
||||
ErrorMessages = errors
|
||||
});
|
||||
result.FailedImports++;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Create product
|
||||
var createProductDto = new CreateProductDto
|
||||
{
|
||||
Name = importDto.Name,
|
||||
Description = importDto.Description,
|
||||
Price = importDto.Price,
|
||||
Weight = importDto.Weight,
|
||||
WeightUnit = weightUnit,
|
||||
StockQuantity = importDto.StockQuantity,
|
||||
CategoryId = categoryId
|
||||
};
|
||||
|
||||
var product = await _productService.CreateProductAsync(createProductDto);
|
||||
result.ImportedProducts.Add(product);
|
||||
|
||||
// Import variations if provided
|
||||
if (!string.IsNullOrEmpty(importDto.Variations))
|
||||
{
|
||||
await ImportProductVariationsAsync(product.Id, importDto.Variations);
|
||||
}
|
||||
|
||||
// Import photos if provided
|
||||
if (!string.IsNullOrEmpty(importDto.PhotoUrls))
|
||||
{
|
||||
await ImportProductPhotosAsync(product.Id, importDto.PhotoUrls);
|
||||
}
|
||||
|
||||
result.SuccessfulImports++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add(new ProductImportErrorDto
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
ProductName = importDto.Name,
|
||||
ErrorMessages = { ex.Message }
|
||||
});
|
||||
result.FailedImports++;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ImportProductVariationsAsync(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);
|
||||
|
||||
for (int i = 0; i < variations.Length; i++)
|
||||
{
|
||||
var parts = variations[i].Split(':', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var variationDto = new CreateProductVariationDto
|
||||
{
|
||||
ProductId = productId,
|
||||
Name = parts[0].Trim(),
|
||||
Description = parts.Length > 3 ? parts[3].Trim() : "",
|
||||
Quantity = int.Parse(parts[1].Trim()),
|
||||
Price = decimal.Parse(parts[2].Trim(), CultureInfo.InvariantCulture),
|
||||
SortOrder = i
|
||||
};
|
||||
|
||||
await _productService.CreateProductVariationAsync(variationDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ImportProductPhotosAsync(Guid productId, string photoUrlsText)
|
||||
{
|
||||
// Format: "url1;url2;url3"
|
||||
var urls = photoUrlsText.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
for (int i = 0; i < urls.Length; i++)
|
||||
{
|
||||
var photoDto = new CreateProductPhotoDto
|
||||
{
|
||||
ProductId = productId,
|
||||
PhotoUrl = urls[i].Trim(),
|
||||
DisplayOrder = i + 1
|
||||
};
|
||||
|
||||
await _productService.AddProductPhotoAsync(photoDto);
|
||||
}
|
||||
}
|
||||
|
||||
public string GenerateTemplateAsCsv()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("Name,Description,Price,Weight,WeightUnit,StockQuantity,CategoryName,IsActive,Variations,PhotoUrls");
|
||||
|
||||
// Example rows
|
||||
sb.AppendLine("\"Example Product 1\",\"High-quality example product with great features\",29.99,150,Grams,50,Electronics,true,\"Single Item:1:29.99;Twin Pack:2:55.00;Triple Pack:3:79.99\",\"https://example.com/photo1.jpg;https://example.com/photo2.jpg\"");
|
||||
sb.AppendLine("\"Example Product 2\",\"Another sample product for import testing\",19.99,200,Grams,25,Clothing,true,\"Individual:1:19.99;Pair:2:35.00\",\"\"");
|
||||
sb.AppendLine("\"Simple Product\",\"Basic product without variations\",9.99,100,Grams,100,Books,true,\"\",\"\"");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task<string> ExportProductsAsCsvAsync()
|
||||
{
|
||||
var products = await _productService.GetAllProductsAsync();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("Name,Description,Price,Weight,WeightUnit,StockQuantity,CategoryName,IsActive,Variations,PhotoUrls");
|
||||
|
||||
foreach (var product in products)
|
||||
{
|
||||
// Build variations string
|
||||
var variationsText = string.Join(";", product.Variations.OrderBy(v => v.SortOrder)
|
||||
.Select(v => $"{v.Name}:{v.Quantity}:{v.Price:F2}"));
|
||||
|
||||
// Build photo URLs string
|
||||
var photoUrlsText = string.Join(";", product.Photos.OrderBy(p => p.SortOrder)
|
||||
.Select(p => p.FilePath));
|
||||
|
||||
sb.AppendLine($"\"{product.Name}\",\"{product.Description}\",{product.Price:F2},{product.Weight:F2},{product.WeightUnit},{product.StockQuantity},\"{product.CategoryName}\",{product.IsActive.ToString().ToLower()},\"{variationsText}\",\"{photoUrlsText}\"");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string[] ParseCsvLine(string line)
|
||||
{
|
||||
var values = new List<string>();
|
||||
var inQuotes = false;
|
||||
var currentValue = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
else if (c == ',' && !inQuotes)
|
||||
{
|
||||
values.Add(currentValue.ToString().Trim());
|
||||
currentValue.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
currentValue.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
values.Add(currentValue.ToString().Trim());
|
||||
return values.ToArray();
|
||||
}
|
||||
|
||||
private string GetValue(string[] values, string[] headers, string columnName, string defaultValue)
|
||||
{
|
||||
var index = Array.FindIndex(headers, h => h.Equals(columnName, StringComparison.OrdinalIgnoreCase));
|
||||
if (index >= 0 && index < values.Length)
|
||||
{
|
||||
var value = values[index].Trim('"', ' ');
|
||||
return string.IsNullOrEmpty(value) ? defaultValue : value;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +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))
|
||||
.Where(p => p.IsActive)
|
||||
.Select(p => new ProductDto
|
||||
{
|
||||
@@ -43,6 +44,20 @@ public class ProductService : IProductService
|
||||
FilePath = ph.FilePath,
|
||||
AltText = ph.AltText,
|
||||
SortOrder = ph.SortOrder
|
||||
}).ToList(),
|
||||
Variations = p.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
|
||||
{
|
||||
Id = v.Id,
|
||||
ProductId = v.ProductId,
|
||||
Name = v.Name,
|
||||
Description = v.Description,
|
||||
Quantity = v.Quantity,
|
||||
Price = v.Price,
|
||||
PricePerUnit = v.PricePerUnit,
|
||||
SortOrder = v.SortOrder,
|
||||
IsActive = v.IsActive,
|
||||
CreatedAt = v.CreatedAt,
|
||||
UpdatedAt = v.UpdatedAt
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
@@ -53,6 +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))
|
||||
.Where(p => p.IsActive && p.CategoryId == categoryId)
|
||||
.Select(p => new ProductDto
|
||||
{
|
||||
@@ -75,6 +91,20 @@ public class ProductService : IProductService
|
||||
FilePath = ph.FilePath,
|
||||
AltText = ph.AltText,
|
||||
SortOrder = ph.SortOrder
|
||||
}).ToList(),
|
||||
Variations = p.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
|
||||
{
|
||||
Id = v.Id,
|
||||
ProductId = v.ProductId,
|
||||
Name = v.Name,
|
||||
Description = v.Description,
|
||||
Quantity = v.Quantity,
|
||||
Price = v.Price,
|
||||
PricePerUnit = v.PricePerUnit,
|
||||
SortOrder = v.SortOrder,
|
||||
IsActive = v.IsActive,
|
||||
CreatedAt = v.CreatedAt,
|
||||
UpdatedAt = v.UpdatedAt
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
@@ -85,6 +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))
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (product == null) return null;
|
||||
@@ -110,6 +141,20 @@ public class ProductService : IProductService
|
||||
FilePath = ph.FilePath,
|
||||
AltText = ph.AltText,
|
||||
SortOrder = ph.SortOrder
|
||||
}).ToList(),
|
||||
Variations = product.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
|
||||
{
|
||||
Id = v.Id,
|
||||
ProductId = v.ProductId,
|
||||
Name = v.Name,
|
||||
Description = v.Description,
|
||||
Quantity = v.Quantity,
|
||||
Price = v.Price,
|
||||
PricePerUnit = v.PricePerUnit,
|
||||
SortOrder = v.SortOrder,
|
||||
IsActive = v.IsActive,
|
||||
CreatedAt = v.CreatedAt,
|
||||
UpdatedAt = v.UpdatedAt
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
@@ -149,7 +194,8 @@ public class ProductService : IProductService
|
||||
CreatedAt = product.CreatedAt,
|
||||
UpdatedAt = product.UpdatedAt,
|
||||
IsActive = product.IsActive,
|
||||
Photos = new List<ProductPhotoDto>()
|
||||
Photos = new List<ProductPhotoDto>(),
|
||||
Variations = new List<ProductVariationDto>()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,6 +339,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))
|
||||
.Where(p => p.IsActive);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
@@ -327,4 +374,149 @@ public class ProductService : IProductService
|
||||
}).ToList()
|
||||
}).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ProductVariationDto> CreateProductVariationAsync(CreateProductVariationDto createVariationDto)
|
||||
{
|
||||
var product = await _context.Products.FindAsync(createVariationDto.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 &&
|
||||
v.IsActive);
|
||||
|
||||
if (existingVariation != null)
|
||||
throw new ArgumentException($"A variation with quantity {createVariationDto.Quantity} already exists for this product");
|
||||
|
||||
var pricePerUnit = createVariationDto.Price / createVariationDto.Quantity;
|
||||
|
||||
var variation = new ProductVariation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProductId = createVariationDto.ProductId,
|
||||
Name = createVariationDto.Name,
|
||||
Description = createVariationDto.Description,
|
||||
Quantity = createVariationDto.Quantity,
|
||||
Price = createVariationDto.Price,
|
||||
PricePerUnit = pricePerUnit,
|
||||
SortOrder = createVariationDto.SortOrder,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.ProductVariations.Add(variation);
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
return new ProductVariationDto
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateProductVariationAsync(Guid id, UpdateProductVariationDto updateVariationDto)
|
||||
{
|
||||
var variation = await _context.ProductVariations.FindAsync(id);
|
||||
if (variation == null) return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(updateVariationDto.Name))
|
||||
variation.Name = updateVariationDto.Name;
|
||||
|
||||
if (!string.IsNullOrEmpty(updateVariationDto.Description))
|
||||
variation.Description = updateVariationDto.Description;
|
||||
|
||||
if (updateVariationDto.Quantity.HasValue)
|
||||
variation.Quantity = updateVariationDto.Quantity.Value;
|
||||
|
||||
if (updateVariationDto.Price.HasValue)
|
||||
variation.Price = updateVariationDto.Price.Value;
|
||||
|
||||
if (updateVariationDto.Quantity.HasValue || updateVariationDto.Price.HasValue)
|
||||
variation.PricePerUnit = variation.Price / variation.Quantity;
|
||||
|
||||
if (updateVariationDto.SortOrder.HasValue)
|
||||
variation.SortOrder = updateVariationDto.SortOrder.Value;
|
||||
|
||||
if (updateVariationDto.IsActive.HasValue)
|
||||
variation.IsActive = updateVariationDto.IsActive.Value;
|
||||
|
||||
variation.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteProductVariationAsync(Guid id)
|
||||
{
|
||||
var variation = await _context.ProductVariations.FindAsync(id);
|
||||
if (variation == null) return false;
|
||||
|
||||
variation.IsActive = false;
|
||||
variation.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ProductVariationDto>> GetProductVariationsAsync(Guid productId)
|
||||
{
|
||||
return await _context.ProductVariations
|
||||
.Where(v => v.ProductId == productId && v.IsActive)
|
||||
.OrderBy(v => v.SortOrder)
|
||||
.Select(v => new ProductVariationDto
|
||||
{
|
||||
Id = v.Id,
|
||||
ProductId = v.ProductId,
|
||||
Name = v.Name,
|
||||
Description = v.Description,
|
||||
Quantity = v.Quantity,
|
||||
Price = v.Price,
|
||||
PricePerUnit = v.PricePerUnit,
|
||||
SortOrder = v.SortOrder,
|
||||
IsActive = v.IsActive,
|
||||
CreatedAt = v.CreatedAt,
|
||||
UpdatedAt = v.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ProductVariationDto?> GetProductVariationByIdAsync(Guid id)
|
||||
{
|
||||
var variation = await _context.ProductVariations.FindAsync(id);
|
||||
if (variation == null) return null;
|
||||
|
||||
return new ProductVariationDto
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user