## 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>
522 lines
19 KiB
C#
522 lines
19 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using LittleShop.Data;
|
|
using LittleShop.Models;
|
|
using LittleShop.DTOs;
|
|
|
|
namespace LittleShop.Services;
|
|
|
|
public class ProductService : IProductService
|
|
{
|
|
private readonly LittleShopContext _context;
|
|
private readonly IWebHostEnvironment _environment;
|
|
|
|
public ProductService(LittleShopContext context, IWebHostEnvironment environment)
|
|
{
|
|
_context = context;
|
|
_environment = environment;
|
|
}
|
|
|
|
public async Task<IEnumerable<ProductDto>> GetAllProductsAsync()
|
|
{
|
|
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
|
|
{
|
|
Id = p.Id,
|
|
Name = p.Name,
|
|
Description = p.Description,
|
|
Price = p.Price,
|
|
Weight = p.Weight,
|
|
WeightUnit = p.WeightUnit,
|
|
StockQuantity = p.StockQuantity,
|
|
CategoryId = p.CategoryId,
|
|
CategoryName = p.Category.Name,
|
|
CreatedAt = p.CreatedAt,
|
|
UpdatedAt = p.UpdatedAt,
|
|
IsActive = p.IsActive,
|
|
Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
|
|
{
|
|
Id = ph.Id,
|
|
FileName = ph.FileName,
|
|
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();
|
|
}
|
|
|
|
public async Task<IEnumerable<ProductDto>> GetProductsByCategoryAsync(Guid categoryId)
|
|
{
|
|
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
|
|
{
|
|
Id = p.Id,
|
|
Name = p.Name,
|
|
Description = p.Description,
|
|
Price = p.Price,
|
|
Weight = p.Weight,
|
|
WeightUnit = p.WeightUnit,
|
|
StockQuantity = p.StockQuantity,
|
|
CategoryId = p.CategoryId,
|
|
CategoryName = p.Category.Name,
|
|
CreatedAt = p.CreatedAt,
|
|
UpdatedAt = p.UpdatedAt,
|
|
IsActive = p.IsActive,
|
|
Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
|
|
{
|
|
Id = ph.Id,
|
|
FileName = ph.FileName,
|
|
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();
|
|
}
|
|
|
|
public async Task<ProductDto?> GetProductByIdAsync(Guid id)
|
|
{
|
|
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;
|
|
|
|
return new ProductDto
|
|
{
|
|
Id = product.Id,
|
|
Name = product.Name,
|
|
Description = product.Description,
|
|
Price = product.Price,
|
|
Weight = product.Weight,
|
|
WeightUnit = product.WeightUnit,
|
|
StockQuantity = product.StockQuantity,
|
|
CategoryId = product.CategoryId,
|
|
CategoryName = product.Category.Name,
|
|
CreatedAt = product.CreatedAt,
|
|
UpdatedAt = product.UpdatedAt,
|
|
IsActive = product.IsActive,
|
|
Photos = product.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
|
|
{
|
|
Id = ph.Id,
|
|
FileName = ph.FileName,
|
|
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()
|
|
};
|
|
}
|
|
|
|
public async Task<ProductDto> CreateProductAsync(CreateProductDto createProductDto)
|
|
{
|
|
var product = new Product
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = createProductDto.Name,
|
|
Description = string.IsNullOrEmpty(createProductDto.Description) ? " " : createProductDto.Description,
|
|
Price = createProductDto.Price,
|
|
Weight = createProductDto.Weight,
|
|
WeightUnit = createProductDto.WeightUnit,
|
|
StockQuantity = createProductDto.StockQuantity,
|
|
CategoryId = createProductDto.CategoryId,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
IsActive = true
|
|
};
|
|
|
|
_context.Products.Add(product);
|
|
await _context.SaveChangesAsync();
|
|
|
|
var category = await _context.Categories.FindAsync(createProductDto.CategoryId);
|
|
|
|
return new ProductDto
|
|
{
|
|
Id = product.Id,
|
|
Name = product.Name,
|
|
Description = product.Description,
|
|
Price = product.Price,
|
|
Weight = product.Weight,
|
|
WeightUnit = product.WeightUnit,
|
|
CategoryId = product.CategoryId,
|
|
CategoryName = category?.Name ?? "",
|
|
CreatedAt = product.CreatedAt,
|
|
UpdatedAt = product.UpdatedAt,
|
|
IsActive = product.IsActive,
|
|
Photos = new List<ProductPhotoDto>(),
|
|
Variations = new List<ProductVariationDto>()
|
|
};
|
|
}
|
|
|
|
public async Task<bool> UpdateProductAsync(Guid id, UpdateProductDto updateProductDto)
|
|
{
|
|
var product = await _context.Products.FindAsync(id);
|
|
if (product == null) return false;
|
|
|
|
if (!string.IsNullOrEmpty(updateProductDto.Name))
|
|
product.Name = updateProductDto.Name;
|
|
|
|
if (!string.IsNullOrEmpty(updateProductDto.Description))
|
|
product.Description = updateProductDto.Description;
|
|
|
|
if (updateProductDto.Price.HasValue)
|
|
product.Price = updateProductDto.Price.Value;
|
|
|
|
if (updateProductDto.Weight.HasValue)
|
|
product.Weight = updateProductDto.Weight.Value;
|
|
|
|
if (updateProductDto.WeightUnit.HasValue)
|
|
product.WeightUnit = updateProductDto.WeightUnit.Value;
|
|
|
|
if (updateProductDto.StockQuantity.HasValue)
|
|
product.StockQuantity = updateProductDto.StockQuantity.Value;
|
|
|
|
if (updateProductDto.CategoryId.HasValue)
|
|
product.CategoryId = updateProductDto.CategoryId.Value;
|
|
|
|
if (updateProductDto.IsActive.HasValue)
|
|
product.IsActive = updateProductDto.IsActive.Value;
|
|
|
|
product.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task<bool> DeleteProductAsync(Guid id)
|
|
{
|
|
var product = await _context.Products.FindAsync(id);
|
|
if (product == null) return false;
|
|
|
|
product.IsActive = false;
|
|
await _context.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task<bool> AddProductPhotoAsync(Guid productId, IFormFile file, string? altText = null)
|
|
{
|
|
var product = await _context.Products.FindAsync(productId);
|
|
if (product == null) return false;
|
|
|
|
var uploadsPath = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", "products");
|
|
Directory.CreateDirectory(uploadsPath);
|
|
|
|
var fileName = $"{Guid.NewGuid()}_{file.FileName}";
|
|
var filePath = Path.Combine(uploadsPath, fileName);
|
|
|
|
using (var stream = new FileStream(filePath, FileMode.Create))
|
|
{
|
|
await file.CopyToAsync(stream);
|
|
}
|
|
|
|
var maxSortOrder = await _context.ProductPhotos
|
|
.Where(pp => pp.ProductId == productId)
|
|
.Select(pp => (int?)pp.SortOrder)
|
|
.MaxAsync() ?? 0;
|
|
|
|
var productPhoto = new ProductPhoto
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ProductId = productId,
|
|
FileName = fileName,
|
|
FilePath = $"/uploads/products/{fileName}",
|
|
AltText = altText,
|
|
SortOrder = maxSortOrder + 1,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_context.ProductPhotos.Add(productPhoto);
|
|
await _context.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task<bool> RemoveProductPhotoAsync(Guid productId, Guid photoId)
|
|
{
|
|
var photo = await _context.ProductPhotos
|
|
.FirstOrDefaultAsync(pp => pp.Id == photoId && pp.ProductId == productId);
|
|
|
|
if (photo == null) return false;
|
|
|
|
var physicalPath = Path.Combine(_environment.WebRootPath, photo.FilePath.TrimStart('/'));
|
|
if (File.Exists(physicalPath))
|
|
{
|
|
File.Delete(physicalPath);
|
|
}
|
|
|
|
_context.ProductPhotos.Remove(photo);
|
|
await _context.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task<ProductPhotoDto?> AddProductPhotoAsync(CreateProductPhotoDto photoDto)
|
|
{
|
|
var product = await _context.Products.FindAsync(photoDto.ProductId);
|
|
if (product == null) return null;
|
|
|
|
var existingPhotos = await _context.ProductPhotos
|
|
.Where(pp => pp.ProductId == photoDto.ProductId)
|
|
.ToListAsync();
|
|
|
|
var maxSortOrder = existingPhotos.Any() ? existingPhotos.Max(pp => pp.SortOrder) : 0;
|
|
|
|
var productPhoto = new ProductPhoto
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ProductId = photoDto.ProductId,
|
|
FileName = Path.GetFileName(photoDto.PhotoUrl),
|
|
FilePath = photoDto.PhotoUrl,
|
|
AltText = photoDto.AltText,
|
|
SortOrder = photoDto.DisplayOrder > 0 ? photoDto.DisplayOrder : maxSortOrder + 1,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_context.ProductPhotos.Add(productPhoto);
|
|
await _context.SaveChangesAsync();
|
|
|
|
return new ProductPhotoDto
|
|
{
|
|
Id = productPhoto.Id,
|
|
FileName = productPhoto.FileName,
|
|
FilePath = productPhoto.FilePath,
|
|
AltText = productPhoto.AltText,
|
|
SortOrder = productPhoto.SortOrder
|
|
};
|
|
}
|
|
|
|
public async Task<IEnumerable<ProductDto>> SearchProductsAsync(string searchTerm)
|
|
{
|
|
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))
|
|
{
|
|
searchTerm = searchTerm.ToLower();
|
|
query = query.Where(p =>
|
|
p.Name.ToLower().Contains(searchTerm) ||
|
|
p.Description.ToLower().Contains(searchTerm));
|
|
}
|
|
|
|
return await query.Select(p => new ProductDto
|
|
{
|
|
Id = p.Id,
|
|
Name = p.Name,
|
|
Description = p.Description,
|
|
Price = p.Price,
|
|
Weight = p.Weight,
|
|
WeightUnit = p.WeightUnit,
|
|
StockQuantity = p.StockQuantity,
|
|
CategoryId = p.CategoryId,
|
|
CategoryName = p.Category.Name,
|
|
CreatedAt = p.CreatedAt,
|
|
UpdatedAt = p.UpdatedAt,
|
|
IsActive = p.IsActive,
|
|
Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto
|
|
{
|
|
Id = ph.Id,
|
|
FileName = ph.FileName,
|
|
FilePath = ph.FilePath,
|
|
AltText = ph.AltText,
|
|
SortOrder = ph.SortOrder
|
|
}).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
|
|
};
|
|
}
|
|
} |