littleshop/LittleShop/Services/ProductService.cs
SilverLabs DevTeam a419bd7a78 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>
2025-09-18 01:39:31 +01:00

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