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>
330 lines
12 KiB
C#
330 lines
12 KiB
C#
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 ImportProductMultiBuysAsync(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 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);
|
|
|
|
for (int i = 0; i < variations.Length; i++)
|
|
{
|
|
var parts = variations[i].Split(':', StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length >= 3)
|
|
{
|
|
var multiBuyDto = new CreateProductMultiBuyDto
|
|
{
|
|
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.CreateProductMultiBuyAsync(multiBuyDto);
|
|
}
|
|
}
|
|
}
|
|
|
|
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.MultiBuys.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;
|
|
}
|
|
} |