Added detailed logging to ParseAndImportVariantLine to output: - Variant name/value - Price override - Stock quantity - Variant type This will help diagnose why variant price and stock aren't showing up 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
895 lines
34 KiB
C#
895 lines
34 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, bool replaceAll = false);
|
|
Task<ProductImportResultDto> ImportFromTextAsync(string csvText, bool replaceAll = false);
|
|
Task<ProductImportResultDto> ImportFromHumanTextAsync(string textContent, bool replaceAll = false);
|
|
Task<int> DeleteAllProductsAndCategoriesAsync();
|
|
Task<int> DeleteAllOrdersAndSalesDataAsync();
|
|
string GenerateTemplateAsCsv();
|
|
string GenerateTemplateAsHumanText();
|
|
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, bool replaceAll = false)
|
|
{
|
|
using var reader = new StreamReader(csvStream);
|
|
var csvText = await reader.ReadToEndAsync();
|
|
return await ImportFromTextAsync(csvText, replaceAll);
|
|
}
|
|
|
|
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 orders, sales data, products, and categories");
|
|
|
|
// Delete orders and sales data first
|
|
var salesDeleted = await DeleteAllOrdersAndSalesDataAsync();
|
|
_logger.LogInformation("Deleted {Count} sales records", salesDeleted);
|
|
|
|
// Then delete products and categories
|
|
var productDeleted = await DeleteAllProductsAndCategoriesAsync();
|
|
_logger.LogInformation("Deleted {Count} product records", productDeleted);
|
|
}
|
|
|
|
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 (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++)
|
|
{
|
|
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;
|
|
}
|
|
|
|
// ===== HUMAN-READABLE TEXT FORMAT IMPORT =====
|
|
|
|
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 orders, sales data, products, and categories");
|
|
|
|
// Delete orders and sales data first
|
|
var salesDeleted = await DeleteAllOrdersAndSalesDataAsync();
|
|
_logger.LogInformation("Deleted {Count} sales records", salesDeleted);
|
|
|
|
// Then delete products and categories
|
|
var productDeleted = await DeleteAllProductsAndCategoriesAsync();
|
|
_logger.LogInformation("Deleted {Count} product records", productDeleted);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(textContent))
|
|
{
|
|
result.Errors.Add(new ProductImportErrorDto
|
|
{
|
|
RowNumber = 0,
|
|
ProductName = "File",
|
|
ErrorMessages = { "Import text is empty" }
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// Get categories and variant collections for lookup
|
|
var categories = await _categoryService.GetAllCategoriesAsync();
|
|
var categoryLookup = categories.ToDictionary(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase);
|
|
|
|
var variantCollectionsList = await _context.VariantCollections
|
|
.Where(vc => vc.IsActive)
|
|
.ToListAsync();
|
|
var variantCollections = variantCollectionsList.ToDictionary(vc => vc.Name, vc => vc.Id, StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Auto-create categories from text blocks if they don't exist
|
|
var productBlocks = SplitIntoProductBlocks(textContent);
|
|
var uniqueCategoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var block in productBlocks)
|
|
{
|
|
var categoryMatch = System.Text.RegularExpressions.Regex.Match(block, @"category:\s*(.+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
if (categoryMatch.Success)
|
|
{
|
|
var categoryName = categoryMatch.Groups[1].Value.Trim();
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Split into product blocks (each starting with #)
|
|
result.TotalRows = productBlocks.Count;
|
|
|
|
for (int i = 0; i < productBlocks.Count; i++)
|
|
{
|
|
try
|
|
{
|
|
await ParseAndImportProductBlock(productBlocks[i], categoryLookup, variantCollections, result, i + 1);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.Errors.Add(new ProductImportErrorDto
|
|
{
|
|
RowNumber = i + 1,
|
|
ProductName = "Parse Error",
|
|
ErrorMessages = { ex.Message }
|
|
});
|
|
result.FailedImports++;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Human text import completed: {Success} successful, {Failed} failed",
|
|
result.SuccessfulImports, result.FailedImports);
|
|
return result;
|
|
}
|
|
|
|
private List<string> SplitIntoProductBlocks(string textContent)
|
|
{
|
|
var blocks = new List<string>();
|
|
var lines = textContent.Split('\n');
|
|
var currentBlock = new StringBuilder();
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
if (line.TrimStart().StartsWith("#") && currentBlock.Length > 0)
|
|
{
|
|
// Start of new product block, save current block
|
|
blocks.Add(currentBlock.ToString());
|
|
currentBlock.Clear();
|
|
}
|
|
currentBlock.AppendLine(line);
|
|
}
|
|
|
|
// Add final block
|
|
if (currentBlock.Length > 0)
|
|
{
|
|
blocks.Add(currentBlock.ToString());
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
private async Task ParseAndImportProductBlock(
|
|
string blockText,
|
|
Dictionary<string, Guid> categoryLookup,
|
|
Dictionary<string, Guid> variantCollectionLookup,
|
|
ProductImportResultDto result,
|
|
int blockNumber)
|
|
{
|
|
var lines = blockText.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
|
var errors = new List<string>();
|
|
|
|
// Parse product header (first line starting with #)
|
|
var headerLine = lines.FirstOrDefault(l => l.TrimStart().StartsWith("#"));
|
|
if (headerLine == null)
|
|
{
|
|
errors.Add("Product block must start with # followed by product name");
|
|
result.Errors.Add(new ProductImportErrorDto
|
|
{
|
|
RowNumber = blockNumber,
|
|
ProductName = "Unknown",
|
|
ErrorMessages = errors
|
|
});
|
|
result.FailedImports++;
|
|
return;
|
|
}
|
|
|
|
// Extract product name and optional variant collection
|
|
var headerParts = headerLine.TrimStart('#').Split(';');
|
|
var productName = headerParts[0].Trim();
|
|
string? variantCollectionName = headerParts.Length > 1 ? headerParts[1].Trim() : null;
|
|
|
|
// Extract description (between <text> and </text>)
|
|
var description = ExtractTextBlock(blockText);
|
|
|
|
// Parse key-value properties
|
|
var properties = ParseKeyValueProperties(lines);
|
|
|
|
// Extract category
|
|
if (!properties.TryGetValue("category", out var categoryName))
|
|
{
|
|
errors.Add("Missing required 'category:' field");
|
|
}
|
|
else if (!categoryLookup.TryGetValue(categoryName, out var categoryId))
|
|
{
|
|
errors.Add($"Category '{categoryName}' not found");
|
|
}
|
|
|
|
// Validate variant collection if specified
|
|
Guid? variantCollectionId = null;
|
|
if (!string.IsNullOrEmpty(variantCollectionName))
|
|
{
|
|
if (!variantCollectionLookup.TryGetValue(variantCollectionName, out var vcId))
|
|
{
|
|
errors.Add($"Variant collection '{variantCollectionName}' not found");
|
|
}
|
|
else
|
|
{
|
|
variantCollectionId = vcId;
|
|
}
|
|
}
|
|
|
|
// Parse numeric properties with defaults
|
|
var price = ParseDecimal(properties.GetValueOrDefault("price", "0"), "price");
|
|
var weight = ParseDecimal(properties.GetValueOrDefault("weight", "0"), "weight");
|
|
var stock = ParseInt(properties.GetValueOrDefault("stock", "0"), "stock");
|
|
var weightUnitStr = properties.GetValueOrDefault("unit", "Grams");
|
|
|
|
if (!Enum.TryParse<ProductWeightUnit>(weightUnitStr, true, out var weightUnit))
|
|
{
|
|
errors.Add($"Invalid weight unit: {weightUnitStr}. Valid values: Grams, Kilograms, Pounds, Ounces");
|
|
}
|
|
|
|
if (errors.Any())
|
|
{
|
|
result.Errors.Add(new ProductImportErrorDto
|
|
{
|
|
RowNumber = blockNumber,
|
|
ProductName = productName,
|
|
ErrorMessages = errors
|
|
});
|
|
result.FailedImports++;
|
|
return;
|
|
}
|
|
|
|
// Create product
|
|
var createProductDto = new CreateProductDto
|
|
{
|
|
Name = productName,
|
|
Description = description ?? "",
|
|
Price = price,
|
|
Weight = weight,
|
|
WeightUnit = weightUnit,
|
|
StockQuantity = stock,
|
|
CategoryId = categoryLookup[categoryName],
|
|
VariantCollectionId = variantCollectionId
|
|
};
|
|
|
|
var product = await _productService.CreateProductAsync(createProductDto);
|
|
result.ImportedProducts.Add(product);
|
|
|
|
// Parse and import variants (lines starting with -)
|
|
var variantLines = lines.Where(l => l.TrimStart().StartsWith("-")).ToList();
|
|
for (int i = 0; i < variantLines.Count; i++)
|
|
{
|
|
await ParseAndImportVariantLine(product.Id, variantLines[i], i, variantCollectionId);
|
|
}
|
|
|
|
// Parse and import multi-buys (lines starting with +)
|
|
var multiBuyLines = lines.Where(l => l.TrimStart().StartsWith("+")).ToList();
|
|
for (int i = 0; i < multiBuyLines.Count; i++)
|
|
{
|
|
await ParseAndImportMultiBuyLine(product.Id, multiBuyLines[i], i);
|
|
}
|
|
|
|
result.SuccessfulImports++;
|
|
}
|
|
|
|
private string? ExtractTextBlock(string blockText)
|
|
{
|
|
var startTag = "<text>";
|
|
var endTag = "</text>";
|
|
|
|
var startIndex = blockText.IndexOf(startTag, StringComparison.OrdinalIgnoreCase);
|
|
if (startIndex == -1) return null;
|
|
|
|
startIndex += startTag.Length;
|
|
var endIndex = blockText.IndexOf(endTag, startIndex, StringComparison.OrdinalIgnoreCase);
|
|
if (endIndex == -1) return null;
|
|
|
|
return blockText.Substring(startIndex, endIndex - startIndex).Trim();
|
|
}
|
|
|
|
private Dictionary<string, string> ParseKeyValueProperties(string[] lines)
|
|
{
|
|
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
var trimmed = line.Trim();
|
|
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#") || trimmed.StartsWith("<") ||
|
|
trimmed.StartsWith("-") || trimmed.StartsWith("+"))
|
|
continue;
|
|
|
|
var colonIndex = trimmed.IndexOf(':');
|
|
if (colonIndex > 0)
|
|
{
|
|
var key = trimmed.Substring(0, colonIndex).Trim();
|
|
var value = trimmed.Substring(colonIndex + 1).Trim();
|
|
properties[key] = value;
|
|
}
|
|
}
|
|
|
|
return properties;
|
|
}
|
|
|
|
private async Task ParseAndImportVariantLine(Guid productId, string line, int sortOrder, Guid? variantCollectionId)
|
|
{
|
|
// Format: - VariantValue; PriceOverride; StockQuantity
|
|
// Example: - 10; 30.00; 100 (for "10 tablets at £30.00 with 100 in stock")
|
|
var content = line.TrimStart('-').Trim();
|
|
var parts = content.Split(';').Select(p => p.Trim()).ToArray();
|
|
|
|
if (parts.Length < 3) return; // Need variant value, price, and stock
|
|
|
|
var variantValue = parts[0]; // The value (e.g., "10", "25", "3.5g", "28g")
|
|
var priceOverride = ParseDecimal(parts[1], "variant price");
|
|
var stockQuantity = ParseInt(parts[2], "variant stock");
|
|
|
|
// Determine variant type based on the value format
|
|
// If it contains 'g' (grams), use "Weight", otherwise use "Quantity"
|
|
string variantType = variantValue.ToLower().Contains("g") ? "Weight" : "Quantity";
|
|
|
|
var variantDto = new CreateProductVariantDto
|
|
{
|
|
ProductId = productId,
|
|
Name = variantValue,
|
|
Price = priceOverride,
|
|
StockLevel = stockQuantity,
|
|
SortOrder = sortOrder,
|
|
VariantType = variantType
|
|
};
|
|
|
|
_logger.LogInformation(
|
|
"Creating variant: Name={Name}, Price={Price}, Stock={Stock}, Type={Type}",
|
|
variantValue, priceOverride, stockQuantity, variantType);
|
|
|
|
await _productService.CreateProductVariantAsync(variantDto);
|
|
}
|
|
|
|
private async Task ParseAndImportMultiBuyLine(Guid productId, string line, int sortOrder)
|
|
{
|
|
// Format: + Name; Quantity; Price
|
|
// Example: + Twin Pack; 2; 19.00
|
|
var content = line.TrimStart('+').Trim();
|
|
var parts = content.Split(';').Select(p => p.Trim()).ToArray();
|
|
|
|
if (parts.Length < 3) return; // Need name, quantity, and price
|
|
|
|
var name = parts[0];
|
|
var quantity = ParseInt(parts[1], "multi-buy quantity");
|
|
var price = ParseDecimal(parts[2], "multi-buy price");
|
|
|
|
var multiBuyDto = new CreateProductMultiBuyDto
|
|
{
|
|
ProductId = productId,
|
|
Name = name,
|
|
Description = "",
|
|
Quantity = quantity,
|
|
Price = price,
|
|
SortOrder = sortOrder
|
|
};
|
|
|
|
await _productService.CreateProductMultiBuyAsync(multiBuyDto);
|
|
}
|
|
|
|
private decimal ParseDecimal(string value, string fieldName)
|
|
{
|
|
if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
|
|
return result;
|
|
throw new FormatException($"Invalid decimal value for {fieldName}: {value}");
|
|
}
|
|
|
|
private int ParseInt(string value, string fieldName)
|
|
{
|
|
if (int.TryParse(value, out var result))
|
|
return result;
|
|
throw new FormatException($"Invalid integer value for {fieldName}: {value}");
|
|
}
|
|
|
|
public string GenerateTemplateAsHumanText()
|
|
{
|
|
return @"# Organic Coffee Beans; Size Options
|
|
<text>
|
|
Premium organic coffee beans sourced from sustainable farms.
|
|
Perfect for espresso or filter coffee.
|
|
Freshly roasted to order.
|
|
</text>
|
|
category: Beverages
|
|
price: 12.00
|
|
weight: 250
|
|
unit: Grams
|
|
stock: 100
|
|
|
|
- 250g; 12.00; 50
|
|
- 500g; 22.00; 30
|
|
- 1kg; 40.00; 20
|
|
|
|
+ Single Bag; 1; 12.00
|
|
+ Twin Pack; 2; 22.00
|
|
+ Family Pack; 3; 32.00
|
|
|
|
# Artisan Chocolate Bar
|
|
<text>
|
|
Handcrafted dark chocolate made with 70% cacao.
|
|
Vegan and gluten-free.
|
|
</text>
|
|
category: Confectionery
|
|
price: 3.50
|
|
weight: 100
|
|
unit: Grams
|
|
stock: 200
|
|
|
|
+ Single Bar; 1; 3.50
|
|
+ Box of 6; 6; 18.00
|
|
|
|
# Premium Tea Blend
|
|
category: Beverages
|
|
price: 8.50
|
|
weight: 50
|
|
unit: Grams
|
|
stock: 75
|
|
|
|
- 50g Tin; 8.50; 40
|
|
- 100g Tin; 15.00; 25
|
|
- 250g Bulk; 32.00; 10
|
|
";
|
|
}
|
|
|
|
public async Task<int> DeleteAllProductsAndCategoriesAsync()
|
|
{
|
|
var deletedCount = 0;
|
|
|
|
try
|
|
{
|
|
// Use ExecuteSqlRaw to avoid EF tracking issues and optimistic concurrency errors
|
|
// Order matters: delete child records first, then parents
|
|
|
|
// Delete order items (references products)
|
|
var orderItemsDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM OrderItems");
|
|
deletedCount += orderItemsDeleted;
|
|
_logger.LogInformation("Deleted {Count} order items", orderItemsDeleted);
|
|
|
|
// Delete product photos
|
|
var photosDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM ProductPhotos");
|
|
deletedCount += photosDeleted;
|
|
_logger.LogInformation("Deleted {Count} product photos", photosDeleted);
|
|
|
|
// Delete product variants
|
|
var variantsDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM ProductVariants");
|
|
deletedCount += variantsDeleted;
|
|
_logger.LogInformation("Deleted {Count} product variants", variantsDeleted);
|
|
|
|
// Delete product multi-buys
|
|
var multiBuysDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM ProductMultiBuys");
|
|
deletedCount += multiBuysDeleted;
|
|
_logger.LogInformation("Deleted {Count} product multi-buys", multiBuysDeleted);
|
|
|
|
// Delete products
|
|
var productsDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM Products");
|
|
deletedCount += productsDeleted;
|
|
_logger.LogInformation("Deleted {Count} products", productsDeleted);
|
|
|
|
// Delete categories
|
|
var categoriesDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM Categories");
|
|
deletedCount += categoriesDeleted;
|
|
_logger.LogInformation("Deleted {Count} categories", categoriesDeleted);
|
|
|
|
_logger.LogInformation("Successfully deleted {TotalCount} total records", deletedCount);
|
|
return deletedCount;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to delete products and categories");
|
|
throw new InvalidOperationException($"Failed to delete data: {ex.Message}", ex);
|
|
}
|
|
}
|
|
|
|
public async Task<int> DeleteAllOrdersAndSalesDataAsync()
|
|
{
|
|
var deletedCount = 0;
|
|
|
|
try
|
|
{
|
|
_logger.LogWarning("DELETING ALL ORDERS AND SALES DATA - This action is irreversible");
|
|
|
|
// Delete in proper order (child tables first)
|
|
|
|
// Delete customer messages (references customers and orders)
|
|
var messagesDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM CustomerMessages");
|
|
deletedCount += messagesDeleted;
|
|
_logger.LogInformation("Deleted {Count} customer messages", messagesDeleted);
|
|
|
|
// Delete crypto payments (references orders)
|
|
var paymentsDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM CryptoPayments");
|
|
deletedCount += paymentsDeleted;
|
|
_logger.LogInformation("Deleted {Count} crypto payments", paymentsDeleted);
|
|
|
|
// Delete order items (references orders and products)
|
|
var orderItemsDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM OrderItems");
|
|
deletedCount += orderItemsDeleted;
|
|
_logger.LogInformation("Deleted {Count} order items", orderItemsDeleted);
|
|
|
|
// Delete orders (parent table)
|
|
var ordersDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM Orders");
|
|
deletedCount += ordersDeleted;
|
|
_logger.LogInformation("Deleted {Count} orders", ordersDeleted);
|
|
|
|
// Delete reviews (sales feedback data)
|
|
var reviewsDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM Reviews");
|
|
deletedCount += reviewsDeleted;
|
|
_logger.LogInformation("Deleted {Count} reviews", reviewsDeleted);
|
|
|
|
// Delete push subscriptions (user notification data)
|
|
var subscriptionsDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM PushSubscriptions");
|
|
deletedCount += subscriptionsDeleted;
|
|
_logger.LogInformation("Deleted {Count} push subscriptions", subscriptionsDeleted);
|
|
|
|
// Delete bot contacts (telegram bot interaction data)
|
|
var contactsDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM BotContacts");
|
|
deletedCount += contactsDeleted;
|
|
_logger.LogInformation("Deleted {Count} bot contacts", contactsDeleted);
|
|
|
|
// Delete bot metrics (analytics data)
|
|
var metricsDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM BotMetrics");
|
|
deletedCount += metricsDeleted;
|
|
_logger.LogInformation("Deleted {Count} bot metrics", metricsDeleted);
|
|
|
|
// Delete customers (will cascade any remaining references)
|
|
var customersDeleted = await _context.Database.ExecuteSqlRawAsync("DELETE FROM Customers");
|
|
deletedCount += customersDeleted;
|
|
_logger.LogInformation("Deleted {Count} customers", customersDeleted);
|
|
|
|
_logger.LogInformation("✅ Successfully deleted {TotalCount} total sales records", deletedCount);
|
|
return deletedCount;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to delete orders and sales data");
|
|
throw new InvalidOperationException($"Failed to delete sales data: {ex.Message}", ex);
|
|
}
|
|
}
|
|
} |