Add: CSV import with Replace All feature and auto-create categories

- Added Replace All checkbox to import UI for clean slate imports
- Implemented DeleteAllProductsAndCategoriesAsync for complete data wipe
- Added auto-creation of categories during CSV import
- Created products_import.csv with 13 products across 4 categories
- Added comprehensive IMPORT_INSTRUCTIONS.md documentation

Technical changes:
- ProductImportService: Added replaceAll parameter to all import methods
- ProductImportService: Categories now auto-created if missing from CSV
- ProductsController: Added replaceAll parameter to Import action
- Import.cshtml: Added Replace All checkbox with danger warnings

Categories: Flour, Cereal, Vitamins, Herbal
Products: 13 products with full variant pricing structures

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
sysadmin
2025-10-08 14:22:13 +01:00
parent be91b3efd7
commit 6c8106ff90
5 changed files with 331 additions and 10 deletions

View File

@@ -10,9 +10,10 @@ namespace LittleShop.Services;
public interface IProductImportService
{
Task<ProductImportResultDto> ImportFromCsvAsync(Stream csvStream);
Task<ProductImportResultDto> ImportFromTextAsync(string csvText);
Task<ProductImportResultDto> ImportFromHumanTextAsync(string textContent);
Task<ProductImportResultDto> ImportFromCsvAsync(Stream csvStream, bool replaceAll = false);
Task<ProductImportResultDto> ImportFromTextAsync(string csvText, bool replaceAll = false);
Task<ProductImportResultDto> ImportFromHumanTextAsync(string textContent, bool replaceAll = false);
Task<int> DeleteAllProductsAndCategoriesAsync();
string GenerateTemplateAsCsv();
string GenerateTemplateAsHumanText();
Task<string> ExportProductsAsCsvAsync();
@@ -37,16 +38,25 @@ public class ProductImportService : IProductImportService
_logger = logger;
}
public async Task<ProductImportResultDto> ImportFromCsvAsync(Stream csvStream)
public async Task<ProductImportResultDto> ImportFromCsvAsync(Stream csvStream, bool replaceAll = false)
{
using var reader = new StreamReader(csvStream);
var csvText = await reader.ReadToEndAsync();
return await ImportFromTextAsync(csvText);
return await ImportFromTextAsync(csvText, replaceAll);
}
public async Task<ProductImportResultDto> ImportFromTextAsync(string csvText)
public async Task<ProductImportResultDto> ImportFromTextAsync(string csvText, bool replaceAll = false)
{
var result = new ProductImportResultDto();
// Replace all existing data if requested
if (replaceAll)
{
_logger.LogWarning("REPLACE ALL: Deleting all existing products, variants, and categories");
var deletedCount = await DeleteAllProductsAndCategoriesAsync();
_logger.LogInformation("Deleted {Count} existing records", deletedCount);
}
var lines = csvText.Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length == 0)
@@ -83,10 +93,41 @@ public class ProductImportService : IProductImportService
result.TotalRows = lines.Length - 1; // Exclude header
// Get categories for lookup
// Get categories for lookup (will be populated as we create new ones)
var categories = await _categoryService.GetAllCategoriesAsync();
var categoryLookup = categories.ToDictionary(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase);
// Auto-create categories from CSV if they don't exist
var uniqueCategoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 1; i < lines.Length; i++)
{
var values = ParseCsvLine(lines[i]);
if (values.Length >= 7)
{
var categoryName = GetValue(values, headers, "CategoryName", "");
if (!string.IsNullOrEmpty(categoryName))
{
uniqueCategoryNames.Add(categoryName);
}
}
}
// Create missing categories
foreach (var categoryName in uniqueCategoryNames)
{
if (!categoryLookup.ContainsKey(categoryName))
{
var createCategoryDto = new CreateCategoryDto
{
Name = categoryName,
Description = $"Auto-created category for {categoryName} products"
};
var newCategory = await _categoryService.CreateCategoryAsync(createCategoryDto);
categoryLookup[categoryName] = newCategory.Id;
_logger.LogInformation("Auto-created category: {CategoryName}", categoryName);
}
}
// Process data rows
for (int i = 1; i < lines.Length; i++)
{
@@ -332,10 +373,18 @@ public class ProductImportService : IProductImportService
// ===== HUMAN-READABLE TEXT FORMAT IMPORT =====
public async Task<ProductImportResultDto> ImportFromHumanTextAsync(string textContent)
public async Task<ProductImportResultDto> ImportFromHumanTextAsync(string textContent, bool replaceAll = false)
{
var result = new ProductImportResultDto();
// Replace all existing data if requested
if (replaceAll)
{
_logger.LogWarning("REPLACE ALL: Deleting all existing products, variants, and categories");
var deletedCount = await DeleteAllProductsAndCategoriesAsync();
_logger.LogInformation("Deleted {Count} existing records", deletedCount);
}
if (string.IsNullOrWhiteSpace(textContent))
{
result.Errors.Add(new ProductImportErrorDto
@@ -677,4 +726,38 @@ stock: 75
- 250g Bulk; 32.00; 10
";
}
public async Task<int> DeleteAllProductsAndCategoriesAsync()
{
var deletedCount = 0;
// Delete all product photos
var photos = await _context.ProductPhotos.ToListAsync();
_context.ProductPhotos.RemoveRange(photos);
deletedCount += photos.Count;
// Delete all product variants
var variants = await _context.ProductVariants.ToListAsync();
_context.ProductVariants.RemoveRange(variants);
deletedCount += variants.Count;
// Delete all product multi-buys
var multiBuys = await _context.ProductMultiBuys.ToListAsync();
_context.ProductMultiBuys.RemoveRange(multiBuys);
deletedCount += multiBuys.Count;
// Delete all products
var products = await _context.Products.ToListAsync();
_context.Products.RemoveRange(products);
deletedCount += products.Count;
// Delete all categories
var categories = await _context.Categories.ToListAsync();
_context.Categories.RemoveRange(categories);
deletedCount += categories.Count;
await _context.SaveChangesAsync();
return deletedCount;
}
}