using System.Globalization; using System.Text; using CsvHelper; using CsvHelper.Configuration; using Microsoft.Extensions.Logging; using PowderCoating.Application.DTOs.Import; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; namespace PowderCoating.Infrastructure.Services; public class CsvImportService : ICsvImportService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; public CsvImportService(IUnitOfWork unitOfWork, ILogger logger) { _unitOfWork = unitOfWork; _logger = logger; } #region Template Generation /// /// Generates a downloadable CSV template pre-populated with a single example customer row. /// The template is produced using the same class that the /// importer reads, so column names and data types are always in sync with the import logic. /// public byte[] GenerateCustomerTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); // Write header csv.WriteHeader(); csv.NextRecord(); // Row 1: commercial customer with company name csv.WriteRecord(new CustomerImportDto { CompanyName = "Example Company Inc.", ContactFirstName = "John", ContactLastName = "Doe", Email = "john@example.com", Phone = "555-1234", MobilePhone = "555-5678", Address = "123 Main St", City = "Springfield", State = "IL", ZipCode = "62701", Country = "USA", CustomerType = "Commercial", PricingTierCode = "Gold", CreditLimit = 5000, PaymentTerms = "Net 30", TaxExempt = false, TaxId = "12-3456789", IsActive = true, Notes = "Sample commercial customer" }); csv.NextRecord(); // Row 2: individual/non-commercial — CompanyName is blank, first+last name used instead csv.WriteRecord(new CustomerImportDto { CompanyName = "", ContactFirstName = "Jane", ContactLastName = "Smith", Email = "jane@example.com", Phone = "555-9999", CustomerType = "Non-Commercial", IsActive = true, Notes = "Individual customer — no company name needed" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Generates a downloadable CSV template pre-populated with two example catalog item rows /// covering different category paths (Automotive/Wheels and Industrial/Railings). /// Multiple examples help users understand the slash-delimited CategoryPath hierarchy format. /// public byte[] GenerateCatalogItemTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); // Write header csv.WriteHeader(); csv.NextRecord(); // Write example rows csv.WriteRecord(new CatalogItemImportDto { CategoryPath = "Automotive/Wheels", ItemName = "Car Wheel - Standard 16\"", SKU = "WHL-16-STD", Description = "Standard 16 inch car wheel", BasePrice = 75.00m, ApproximateArea = 4.5m, EstimatedMinutes = 45, RequiresSandblasting = true, RequiresMasking = true, IsActive = true }); csv.NextRecord(); csv.WriteRecord(new CatalogItemImportDto { CategoryPath = "Industrial/Railings", ItemName = "Handrail - 10 ft section", SKU = "RAIL-10FT", Description = "10 foot handrail section", BasePrice = 150.00m, ApproximateArea = 12.0m, EstimatedMinutes = 90, RequiresSandblasting = true, RequiresMasking = false, IsActive = true }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Generates a downloadable CSV template pre-populated with two powder coating inventory items. /// Two rows are provided to show how color-specific fields (ColorName, ColorCode, Finish) and /// powder-specific coverage/efficiency fields differ between SKUs. /// public byte[] GenerateInventoryItemTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); // Write header csv.WriteHeader(); csv.NextRecord(); // Write example rows csv.WriteRecord(new InventoryItemImportDto { SKU = "PWD-BLK-001", ItemName = "Black Powder Coating", Description = "Gloss black powder coat for general metal work", CategoryName = "Powder", Manufacturer = "Tiger Drylac", ManufacturerPartNumber = "049/90005", ColorName = "Black", ColorCode = "RAL 9005", Finish = "Gloss", VendorName = "Tiger Drylac USA", VendorPartNumber = "TD-BLK-9005", QuantityInStock = 500, UnitOfMeasure = "lbs", UnitCost = 3.50m, LastPurchasePrice = 3.50m, ReorderPoint = 100, ReorderQuantity = 200, MinimumStock = 50, MaximumStock = 1000, CoverageSqFtPerLb = 30, TransferEfficiencyPct = 65, Location = "Shelf A-1", IsActive = true, Notes = "Glossy finish" }); csv.NextRecord(); csv.WriteRecord(new InventoryItemImportDto { SKU = "PWD-WHT-001", ItemName = "White Powder Coating", Description = "Bright white powder coat", CategoryName = "Powder", Manufacturer = "Tiger Drylac", ManufacturerPartNumber = "049/90010", ColorName = "White", ColorCode = "RAL 9010", Finish = "Gloss", VendorName = "Tiger Drylac USA", VendorPartNumber = "TD-WHT-9010", QuantityInStock = 350, UnitOfMeasure = "lbs", UnitCost = 3.75m, LastPurchasePrice = 3.75m, ReorderPoint = 75, ReorderQuantity = 150, MinimumStock = 25, MaximumStock = 500, CoverageSqFtPerLb = 30, TransferEfficiencyPct = 65, Location = "Shelf A-2", IsActive = true, Notes = "Bright white" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Generates a downloadable CSV template with a single example quote row. /// The example includes both customer-linked and prospect-style columns so users can see /// that CustomerEmail is optional when importing prospect quotes. /// public byte[] GenerateQuoteTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new QuoteImportDto { QuoteNumber = "QT-2601-0001", CustomerEmail = "customer@example.com", Status = "Draft", QuoteDate = DateTime.Today, ExpirationDate = DateTime.Today.AddDays(30), Subtotal = 500.00m, TaxAmount = 40.00m, Total = 540.00m, Notes = "Sample quote", TermsAndConditions = "Net 30" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Generates a downloadable CSV template with a single example job row. /// CustomerEmail is optional — the importer falls back to CustomerName when email is blank. /// At least one of the two must be present and must match an existing customer record. /// public byte[] GenerateJobTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new JobImportDto { JobNumber = "JOB-2601-0001", CustomerEmail = "customer@example.com", CustomerName = "Acme Corp (used if email is blank or not found)", Status = "Pending", Priority = "Normal", ScheduledDate = DateTime.Today.AddDays(7), DueDate = DateTime.Today.AddDays(14), FinalPrice = 750.00m, CustomerPO = "PO-12345", SpecialInstructions = "Handle with care", Notes = "Sample job" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Generates a downloadable CSV template with a single example appointment row. /// ScheduledStart and ScheduledEnd are shown with time components to make it clear /// that appointments carry datetime precision, not just dates. /// public byte[] GenerateAppointmentTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new AppointmentImportDto { AppointmentNumber = "APT-2601-0001", CustomerEmail = "customer@example.com", AppointmentType = "Consultation", Status = "Scheduled", ScheduledStart = DateTime.Today.AddDays(3).AddHours(10), ScheduledEnd = DateTime.Today.AddDays(3).AddHours(11), Title = "Initial Consultation", Description = "Discuss project requirements", Location = "Main Office", Notes = "Customer requested morning appointment" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Generates a downloadable CSV template with a single example equipment row. /// The Status column is populated so users understand the allowed enum values /// (Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired). /// public byte[] GenerateEquipmentTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new EquipmentImportDto { EquipmentName = "Powder Coating Oven #1", EquipmentNumber = "EQ-001", EquipmentType = "Oven", Manufacturer = "Coating Solutions Inc", Model = "CS-3000", SerialNumber = "SN-123456789", PurchaseDate = DateTime.Today.AddYears(-2), PurchasePrice = 25000.00m, WarrantyExpiration = DateTime.Today.AddYears(1), Location = "Production Floor - Bay 1", RecommendedMaintenanceIntervalDays = 90, Status = "Operational", IsActive = true, Notes = "Regular maintenance required quarterly" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Generates a downloadable CSV template with a single example maintenance record row. /// EquipmentName must match an existing equipment record exactly because the importer /// resolves the FK by name lookup rather than requiring the user to know internal IDs. /// public byte[] GenerateMaintenanceTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new MaintenanceImportDto { EquipmentName = "Powder Coating Oven #1", MaintenanceType = "Preventive", ScheduledDate = DateTime.Today.AddDays(30), Status = "Scheduled", Priority = "Normal", LaborCost = 150.00m, PartsCost = 75.00m, TotalCost = 225.00m, Description = "Quarterly maintenance check", Notes = "Check heating elements and seals" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } #endregion #region Import Methods /// /// Imports customers from a CSV stream and persists valid rows to the database for the given company. /// The import uses a two-phase approach: all rows are parsed and validated first, then each validated /// entity is saved individually so that a single bad row does not roll back the entire batch. /// Duplicate detection runs against both existing DB records (by email) and within the import file /// itself, catching cases where the same email appears twice in one upload. /// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the /// customer is imported without a tier rather than being skipped entirely. /// Contact names are split on the first space into FirstName / LastName because the CSV carries a /// single "ContactName" column — this matches how the customer form presents the data. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own the imported records. public async Task ImportCustomersAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} customers for company {CompanyId}", records.Count, companyId); // Get all existing customers for duplicate detection var existingCustomers = await _unitOfWork.Customers.GetAllAsync(); var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email)) .ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase); // Get pricing tiers for lookup var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync(); var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase); var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>(); foreach (var record in records) { rowNumber++; try { // Strip any literal quote characters that QB/Excel may wrap around field values var cleanCompanyName = StripQuotes(record.CompanyName); var cleanEmail = StripQuotes(record.Email); var firstName = StripQuotes(record.ContactFirstName)?.Trim(); var lastName = StripQuotes(record.ContactLastName)?.Trim(); // Non-commercial (individual) customers may have no company name — use contact name silently if (string.IsNullOrWhiteSpace(cleanCompanyName)) { var derivedName = $"{firstName} {lastName}".Trim(); if (string.IsNullOrWhiteSpace(derivedName)) { result.Errors.Add($"Row {rowNumber}: Either CompanyName or ContactFirstName/ContactLastName is required."); result.ErrorCount++; continue; } cleanCompanyName = derivedName; } // Check for duplicate email in existing data if (!string.IsNullOrEmpty(cleanEmail) && existingEmails.ContainsKey(cleanEmail.ToLower())) { result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping."); result.SkippedCount++; continue; } // Check for duplicate email within the import batch if (!string.IsNullOrEmpty(cleanEmail) && customersToImport.Any(x => x.Email.Equals(cleanEmail, StringComparison.OrdinalIgnoreCase))) { result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping."); result.SkippedCount++; continue; } // Resolve pricing tier int? pricingTierId = null; if (!string.IsNullOrWhiteSpace(record.PricingTierCode)) { if (pricingTierDict.TryGetValue(record.PricingTierCode.ToUpper(), out var tier)) { pricingTierId = tier.Id; } else { result.Warnings.Add($"Row {rowNumber}: Pricing tier '{record.PricingTierCode}' not found. Customer will have no pricing tier."); } } // Determine customer type bool isCommercial = string.Equals(record.CustomerType, "Commercial", StringComparison.OrdinalIgnoreCase); // Create customer entity var customer = new Customer { CompanyId = companyId, CompanyName = cleanCompanyName, ContactFirstName = firstName, ContactLastName = lastName, Email = cleanEmail, Phone = record.Phone?.Trim(), MobilePhone = record.MobilePhone?.Trim(), Address = record.Address?.Trim(), City = record.City?.Trim(), State = record.State?.Trim(), ZipCode = record.ZipCode?.Trim(), Country = record.Country?.Trim() ?? "USA", IsCommercial = isCommercial, TaxId = record.TaxId?.Trim(), PricingTierId = pricingTierId, CreditLimit = record.CreditLimit ?? 0, PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30", IsTaxExempt = record.TaxExempt ?? false, GeneralNotes = record.Notes?.Trim(), IsActive = record.IsActive ?? true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty)); } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; _logger.LogWarning(ex, "Error processing customer at row {RowNumber}", rowNumber); } } // Save customers one-by-one to isolate database errors foreach (var (customerRowNumber, customer, email) in customersToImport) { try { await _unitOfWork.Customers.AddAsync(customer); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true) { result.Warnings.Add($"Row {customerRowNumber}: Customer with email '{email}' already exists in database (detected during save). Skipping."); result.ErrorCount++; _logger.LogWarning("Duplicate email '{Email}' detected during save at row {RowNumber}", email, customerRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } catch (Exception ex) { result.Errors.Add($"Row {customerRowNumber}: Database error - {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Error saving customer at row {RowNumber}", customerRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } } _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.Success = false; _logger.LogError(ex, "Fatal error importing customers"); } return result; } /// /// Imports catalog items from a CSV stream, auto-creating the category hierarchy as needed. /// Categories are expressed as slash-delimited paths (e.g. "Automotive/Wheels") and resolved /// via , which creates missing parent or leaf nodes /// on the fly and caches them for the duration of the import to avoid redundant DB calls. /// Duplicate detection is keyed on SKU; items with no SKU or an already-existing SKU are skipped. /// Accounting ledger accounts (, ) /// are stamped on every item so that new catalog items can integrate with the AP/AR module without /// manual post-import account assignment. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own the imported records. /// Optional revenue GL account to assign to every imported item. /// Optional COGS GL account to assign to every imported item. public async Task ImportCatalogItemsAsync(Stream csvStream, int companyId, int? revenueAccountId = null, int? cogsAccountId = null) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} catalog items for company {CompanyId}", records.Count, companyId); // Get all existing catalog items for duplicate detection var existingItems = await _unitOfWork.CatalogItems.GetAllAsync(); var existingSkus = existingItems.Where(i => !string.IsNullOrEmpty(i.SKU)) .ToDictionary(i => i.SKU!.ToUpper(), i => i, StringComparer.OrdinalIgnoreCase); // Category cache var categoryCache = new Dictionary(); var catalogItemsToImport = new List<(int RowNumber, CatalogItem Item, string SKU)>(); foreach (var record in records) { rowNumber++; try { // Validate required fields if (string.IsNullOrWhiteSpace(record.ItemName)) { result.Errors.Add($"Row {rowNumber}: ItemName is required."); result.ErrorCount++; continue; } if (string.IsNullOrWhiteSpace(record.CategoryPath)) { result.Errors.Add($"Row {rowNumber}: CategoryPath is required."); result.ErrorCount++; continue; } // Check for duplicate SKU in existing data if (!string.IsNullOrEmpty(record.SKU) && existingSkus.ContainsKey(record.SKU.ToUpper())) { result.Warnings.Add($"Row {rowNumber}: Catalog item with SKU '{record.SKU}' already exists in database. Skipping."); result.SkippedCount++; continue; } // Check for duplicate SKU within the import batch if (!string.IsNullOrEmpty(record.SKU) && catalogItemsToImport.Any(x => x.SKU.Equals(record.SKU, StringComparison.OrdinalIgnoreCase))) { result.Warnings.Add($"Row {rowNumber}: Duplicate SKU '{record.SKU}' found in import file. Skipping."); result.SkippedCount++; continue; } // Resolve or create category int categoryId = await ResolveOrCreateCategoryAsync(record.CategoryPath, companyId, categoryCache); // Create catalog item entity var catalogItem = new CatalogItem { CompanyId = companyId, Name = record.ItemName.Trim(), SKU = record.SKU?.Trim(), Description = record.Description?.Trim(), CategoryId = categoryId, DefaultPrice = record.BasePrice ?? 0, ApproximateArea = record.ApproximateArea, DefaultEstimatedMinutes = record.EstimatedMinutes, DefaultRequiresSandblasting = record.RequiresSandblasting ?? false, DefaultRequiresMasking = record.RequiresMasking ?? false, IsActive = record.IsActive ?? true, DisplayOrder = 0, RevenueAccountId = revenueAccountId, CogsAccountId = cogsAccountId, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; catalogItemsToImport.Add((rowNumber, catalogItem, record.SKU?.Trim() ?? string.Empty)); } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; _logger.LogWarning(ex, "Error processing catalog item at row {RowNumber}", rowNumber); } } // Save catalog items one-by-one to isolate database errors foreach (var (itemRowNumber, item, sku) in catalogItemsToImport) { try { await _unitOfWork.CatalogItems.AddAsync(item); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true) { result.Warnings.Add($"Row {itemRowNumber}: Catalog item with SKU '{sku}' already exists in database (detected during save). Skipping."); result.ErrorCount++; _logger.LogWarning("Duplicate SKU '{SKU}' detected during save at row {RowNumber}", sku, itemRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } catch (Exception ex) { result.Errors.Add($"Row {itemRowNumber}: Database error - {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Error saving catalog item at row {RowNumber}", itemRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } } _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.Success = false; _logger.LogError(ex, "Fatal error importing catalog items"); } return result; } /// /// Imports inventory items from a CSV stream with soft-delete awareness and vendor/category resolution. /// The method intentionally bypasses global query filters when loading existing items so that /// soft-deleted SKUs are visible — if an incoming SKU matches a deleted record the item is restored /// and updated in place rather than inserting a duplicate, which would violate the unique SKU index. /// Category matching uses a two-tier strategy: first try exact display name or category code, /// then fall back to a built-in alias table (e.g. "Powder Coatings" → POWDER) that covers common /// customer naming variations. Unresolved categories are warned, not blocked. /// Vendor lookup is name-based and scoped to the target company to avoid cross-tenant leakage. /// Items are saved individually (not in a batch) so a DB error on one row does not orphan others. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own the imported records. /// Optional inventory asset GL account stamped on every new item. /// Optional COGS GL account stamped on every new item. public async Task ImportInventoryItemsAsync(Stream csvStream, int companyId, int? inventoryAccountId = null, int? cogsAccountId = null) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} inventory items for company {CompanyId}", records.Count, companyId); // Load only THIS company's items (ignoreQueryFilters to include soft-deleted rows). // Using FindAsync with an explicit companyId predicate rather than GetAllAsync(ignoreQueryFilters:true) // prevents cross-tenant SKU collisions from causing a ToDictionary duplicate-key crash when // two different companies happen to share the same SKU. var companyItemsIncDeleted = await _unitOfWork.InventoryItems .FindAsync(i => i.CompanyId == companyId, ignoreQueryFilters: true); var existingSkus = companyItemsIncDeleted .Where(i => !i.IsDeleted && !string.IsNullOrWhiteSpace(i.SKU)) .ToDictionary(i => i.SKU.Trim().ToUpper(), i => i, StringComparer.OrdinalIgnoreCase); var deletedSkus = companyItemsIncDeleted .Where(i => i.IsDeleted && !string.IsNullOrWhiteSpace(i.SKU)) .ToDictionary(i => i.SKU.Trim().ToUpper(), i => i, StringComparer.OrdinalIgnoreCase); // Build vendor lookup by company name for this company var allVendors = await _unitOfWork.Vendors.GetAllAsync(); var vendorDict = allVendors .Where(v => v.CompanyId == companyId) .GroupBy(v => v.CompanyName.ToUpper()) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); // Build category lookup for THIS company only — using another company's category Id // would be silently excluded by EF's multi-tenancy query filter at runtime. var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); var companyCategories = allCategories.Where(c => c.CompanyId == companyId).ToList(); var categoryDict = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var cat in companyCategories) { categoryDict.TryAdd(cat.DisplayName, cat); categoryDict.TryAdd(cat.CategoryCode, cat); } // Common aliases customers might type in the CSV var categoryAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Powder Coatings", "POWDER" }, { "Powder Coating", "POWDER" }, { "Powders", "POWDER" }, { "Primers", "PRIMER" }, { "Cleaners", "CLEANER" }, { "Masking", "MASKING" }, { "Masking Tape", "MASKING" }, { "Abrasive", "ABRASIVE" }, { "Abrasives", "ABRASIVE" }, { "Blast Media", "ABRASIVE" }, { "Chemicals", "CHEMICAL" }, { "Consumable", "CONSUMABLE" }, { "Tools & Equipment", "TOOL" }, { "Equipment", "TOOL" }, { "General", "OTHER" }, }; var itemsToImport = new List<(int RowNumber, InventoryItem Item, string SKU)>(); foreach (var record in records) { rowNumber++; try { // Validate required fields if (string.IsNullOrWhiteSpace(record.SKU)) { result.Errors.Add($"Row {rowNumber}: SKU is required."); result.ErrorCount++; continue; } if (string.IsNullOrWhiteSpace(record.ItemName)) { result.Errors.Add($"Row {rowNumber}: ItemName is required."); result.ErrorCount++; continue; } // Check for duplicate SKU in existing data if (existingSkus.ContainsKey(record.SKU.Trim().ToUpper())) { result.Warnings.Add($"Row {rowNumber}: Inventory item with SKU '{record.SKU.Trim()}' already exists. Skipping."); result.SkippedCount++; continue; } // Check for duplicate SKU within the import batch if (itemsToImport.Any(x => x.SKU.Equals(record.SKU, StringComparison.OrdinalIgnoreCase))) { result.Warnings.Add($"Row {rowNumber}: Duplicate SKU '{record.SKU}' found in import file. Skipping."); result.SkippedCount++; continue; } // Resolve category lookup — auto-create if not found InventoryCategoryLookup? resolvedCategory = null; if (!string.IsNullOrWhiteSpace(record.CategoryName)) { var catKey = record.CategoryName.Trim(); if (!categoryDict.TryGetValue(catKey, out resolvedCategory) && categoryAliases.TryGetValue(catKey, out var aliasCode)) { categoryDict.TryGetValue(aliasCode, out resolvedCategory); } if (resolvedCategory == null) { // Create on-the-fly so the import doesn't silently drop category data var newCode = System.Text.RegularExpressions.Regex .Replace(catKey.ToUpper(), @"[^A-Z0-9]", "_"); resolvedCategory = new InventoryCategoryLookup { CompanyId = companyId, CategoryCode = newCode, DisplayName = catKey, DisplayOrder = 99, IsActive = true, IsSystemDefined = false, IsCoating = false, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await _unitOfWork.InventoryCategoryLookups.AddAsync(resolvedCategory); await _unitOfWork.CompleteAsync(); categoryDict[resolvedCategory.DisplayName] = resolvedCategory; categoryDict[resolvedCategory.CategoryCode] = resolvedCategory; companyCategories.Add(resolvedCategory); } } // Resolve vendor by name int? primaryVendorId = null; if (!string.IsNullOrWhiteSpace(record.VendorName)) { if (vendorDict.TryGetValue(record.VendorName.Trim().ToUpper(), out var vendor)) { primaryVendorId = vendor.Id; } else { result.Warnings.Add($"Row {rowNumber}: Vendor '{record.VendorName}' not found. Item will have no primary vendor assigned."); } } // If the SKU belongs to a previously soft-deleted item, restore and update it // instead of inserting a new record (avoids unique constraint violation). if (deletedSkus.TryGetValue(record.SKU.ToUpper(), out var deletedItem)) { deletedItem.IsDeleted = false; deletedItem.Name = record.ItemName.Trim(); deletedItem.Description = record.Description?.Trim(); deletedItem.InventoryCategoryId = resolvedCategory?.Id ?? deletedItem.InventoryCategoryId; deletedItem.Category = resolvedCategory?.DisplayName ?? record.CategoryName?.Trim() ?? deletedItem.Category; deletedItem.Manufacturer = record.Manufacturer?.Trim(); deletedItem.ManufacturerPartNumber = record.ManufacturerPartNumber?.Trim(); deletedItem.ColorName = record.ColorName?.Trim(); deletedItem.ColorCode = record.ColorCode?.Trim(); deletedItem.Finish = record.Finish?.Trim(); deletedItem.PrimaryVendorId = primaryVendorId ?? deletedItem.PrimaryVendorId; deletedItem.VendorPartNumber = record.VendorPartNumber?.Trim(); deletedItem.QuantityOnHand = record.QuantityInStock ?? 0; deletedItem.UnitOfMeasure = record.UnitOfMeasure?.Trim() ?? deletedItem.UnitOfMeasure; deletedItem.UnitCost = record.UnitCost ?? deletedItem.UnitCost; deletedItem.LastPurchasePrice = record.LastPurchasePrice ?? record.UnitCost ?? deletedItem.LastPurchasePrice; deletedItem.ReorderPoint = record.ReorderPoint ?? deletedItem.ReorderPoint; deletedItem.ReorderQuantity = record.ReorderQuantity ?? deletedItem.ReorderQuantity; deletedItem.MinimumStock = record.MinimumStock ?? deletedItem.MinimumStock; deletedItem.MaximumStock = record.MaximumStock ?? deletedItem.MaximumStock; deletedItem.CoverageSqFtPerLb = record.CoverageSqFtPerLb ?? deletedItem.CoverageSqFtPerLb; deletedItem.TransferEfficiency = record.TransferEfficiencyPct ?? deletedItem.TransferEfficiency; deletedItem.Location = record.Location?.Trim(); deletedItem.Notes = record.Notes?.Trim(); deletedItem.IsActive = record.IsActive ?? true; deletedItem.InventoryAccountId = inventoryAccountId ?? deletedItem.InventoryAccountId; deletedItem.CogsAccountId = cogsAccountId ?? deletedItem.CogsAccountId; deletedItem.UpdatedAt = DateTime.UtcNow; result.Warnings.Add($"Row {rowNumber}: SKU '{record.SKU}' was previously deleted — item restored and updated."); itemsToImport.Add((rowNumber, deletedItem, record.SKU.Trim())); continue; } // Create new inventory item entity var inventoryItem = new InventoryItem { CompanyId = companyId, SKU = record.SKU.Trim(), Name = record.ItemName.Trim(), Description = record.Description?.Trim(), InventoryCategoryId = resolvedCategory?.Id, Category = resolvedCategory?.DisplayName ?? record.CategoryName?.Trim() ?? "General", Manufacturer = record.Manufacturer?.Trim(), ManufacturerPartNumber = record.ManufacturerPartNumber?.Trim(), ColorName = record.ColorName?.Trim(), ColorCode = record.ColorCode?.Trim(), Finish = record.Finish?.Trim(), PrimaryVendorId = primaryVendorId, VendorPartNumber = record.VendorPartNumber?.Trim(), QuantityOnHand = record.QuantityInStock ?? 0, UnitOfMeasure = record.UnitOfMeasure?.Trim() ?? "units", UnitCost = record.UnitCost ?? 0, LastPurchasePrice = record.LastPurchasePrice ?? record.UnitCost ?? 0, ReorderPoint = record.ReorderPoint ?? 0, ReorderQuantity = record.ReorderQuantity ?? 0, MinimumStock = record.MinimumStock ?? 0, MaximumStock = record.MaximumStock ?? 0, CoverageSqFtPerLb = record.CoverageSqFtPerLb, TransferEfficiency = record.TransferEfficiencyPct, Location = record.Location?.Trim(), Notes = record.Notes?.Trim(), IsActive = record.IsActive ?? true, InventoryAccountId = inventoryAccountId, CogsAccountId = cogsAccountId, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; itemsToImport.Add((rowNumber, inventoryItem, record.SKU.Trim())); } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; _logger.LogWarning(ex, "Error processing inventory item at row {RowNumber}", rowNumber); } } // Save items one-by-one to isolate database errors foreach (var (itemRowNumber, item, sku) in itemsToImport) { try { // Restored items already exist in the DB; new items need to be inserted. if (item.Id > 0) await _unitOfWork.InventoryItems.UpdateAsync(item); else await _unitOfWork.InventoryItems.AddAsync(item); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true) { result.Warnings.Add($"Row {itemRowNumber}: SKU '{sku}' already exists in database (detected during save). Skipping."); result.ErrorCount++; _logger.LogWarning("Duplicate SKU '{SKU}' detected during save at row {RowNumber}", sku, itemRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } catch (Exception ex) { result.Errors.Add($"Row {itemRowNumber}: Database error - {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Error saving inventory item at row {RowNumber}", itemRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } } _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.Success = false; _logger.LogError(ex, "Fatal error importing inventory items"); } return result; } /// /// Imports quote headers from a CSV stream, supporting both customer-linked and prospect quotes. /// Customer resolution is by email address. When a CustomerEmail is supplied but no matching /// customer is found the row is not rejected — instead it is stored as a prospect quote with /// the raw email preserved in ProspectEmail, because the quote data itself is still useful. /// Quote status is resolved against the QuoteStatusLookup table; unrecognised values fall back /// to "Draft" with a warning so that legacy exports using display labels still import cleanly. /// Duplicate detection uses QuoteNumber as the unique key, checking both the DB and the current /// import batch to guard against re-importing the same file twice. /// Note: this method imports quote headers only; line items are not part of the CSV format. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own the imported records. public async Task ImportQuotesAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} quotes for company {CompanyId}", records.Count, companyId); // Get all existing quotes for duplicate detection var existingQuotes = await _unitOfWork.Quotes.GetAllAsync(); var existingQuoteNumbers = existingQuotes.Where(q => !string.IsNullOrEmpty(q.QuoteNumber)) .ToDictionary(q => q.QuoteNumber.ToUpper(), q => q, StringComparer.OrdinalIgnoreCase); // Get customers for lookup — email-first, name-fallback (mirrors ImportJobsAsync) var customers = await _unitOfWork.Customers.GetAllAsync(); var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email)) .ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase); var customerByName = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var c in customers) { var displayName = !string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName.Trim() : $"{c.ContactFirstName} {c.ContactLastName}".Trim(); if (!string.IsNullOrEmpty(displayName)) customerByName.TryAdd(displayName, c); } // Get quote statuses for lookup var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync(); var quoteStatusDict = quoteStatuses.ToDictionary(qs => qs.StatusCode.ToUpper(), qs => qs, StringComparer.OrdinalIgnoreCase); var quotesToImport = new List<(int RowNumber, Quote Quote, string QuoteNumber)>(); foreach (var record in records) { rowNumber++; try { // Validate required fields if (string.IsNullOrWhiteSpace(record.QuoteNumber)) { result.Errors.Add($"Row {rowNumber}: QuoteNumber is required."); result.ErrorCount++; continue; } // Check for duplicate quote number in existing data if (existingQuoteNumbers.ContainsKey(record.QuoteNumber.ToUpper())) { result.Warnings.Add($"Row {rowNumber}: Quote with number '{record.QuoteNumber}' already exists in database. Skipping."); result.ErrorCount++; continue; } // Check for duplicate quote number within the import batch if (quotesToImport.Any(x => x.QuoteNumber.Equals(record.QuoteNumber, StringComparison.OrdinalIgnoreCase))) { result.Warnings.Add($"Row {rowNumber}: Duplicate quote number '{record.QuoteNumber}' found in import file. Skipping."); result.ErrorCount++; continue; } // Resolve customer — try email first, then CustomerName fallback int? customerId = null; if (!string.IsNullOrEmpty(record.CustomerEmail)) { if (customerByEmail.TryGetValue(record.CustomerEmail.Trim().ToLower(), out var byEmail)) { customerId = byEmail.Id; } else { result.Warnings.Add($"Row {rowNumber}: Customer with email '{record.CustomerEmail}' not found. Trying CustomerName fallback."); } } if (!customerId.HasValue && !string.IsNullOrWhiteSpace(record.CustomerName)) { if (customerByName.TryGetValue(record.CustomerName.Trim(), out var byName)) { customerId = byName.Id; if (!string.IsNullOrEmpty(record.CustomerEmail)) result.Warnings.Add($"Row {rowNumber}: Matched customer by name '{record.CustomerName}'."); } else { result.Warnings.Add($"Row {rowNumber}: Customer '{record.CustomerName}' not found by name. Will treat as prospect quote."); } } // Resolve quote status int quoteStatusId; if (!quoteStatusDict.TryGetValue(record.Status.ToUpper(), out var quoteStatus)) { // Default to "Draft" if status not found quoteStatus = quoteStatusDict.Values.FirstOrDefault(qs => qs.StatusCode.Equals("DRAFT", StringComparison.OrdinalIgnoreCase)); if (quoteStatus == null) { result.Errors.Add($"Row {rowNumber}: Quote status '{record.Status}' not found and no default 'Draft' status exists."); result.ErrorCount++; continue; } result.Warnings.Add($"Row {rowNumber}: Quote status '{record.Status}' not found. Using 'Draft' as default."); } quoteStatusId = quoteStatus.Id; // Create quote entity var quote = new Quote { CompanyId = companyId, QuoteNumber = record.QuoteNumber.Trim(), CustomerId = customerId, QuoteStatusId = quoteStatusId, QuoteDate = record.QuoteDate, ExpirationDate = record.ExpirationDate, SubTotal = record.Subtotal, TaxAmount = record.TaxAmount, Total = record.Total, Notes = record.Notes?.Trim(), Terms = record.TermsAndConditions?.Trim(), IsCommercial = customerId.HasValue, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; // If this is a prospect quote, populate prospect fields. // Fall back to CustomerName as the prospect company name when ProspectCompany // is blank — handles the case where the user had a name in CustomerName that // didn't match any existing customer record. // Store null (not empty string) so the ?? display fallback chain works correctly; // empty strings block the fallback to ProspectContactName in mapped DTOs. if (!customerId.HasValue) { var rawCompany = !string.IsNullOrWhiteSpace(record.ProspectCompany) ? record.ProspectCompany.Trim() : record.CustomerName?.Trim(); quote.ProspectCompanyName = string.IsNullOrWhiteSpace(rawCompany) ? null : rawCompany; var rawContact = record.ProspectContact?.Trim(); quote.ProspectContactName = string.IsNullOrWhiteSpace(rawContact) ? null : rawContact; var rawEmail = record.ProspectEmail?.Trim(); quote.ProspectEmail = string.IsNullOrWhiteSpace(rawEmail) ? null : rawEmail; var rawPhone = record.ProspectPhone?.Trim(); quote.ProspectPhone = string.IsNullOrWhiteSpace(rawPhone) ? null : rawPhone; } quotesToImport.Add((rowNumber, quote, record.QuoteNumber.Trim())); } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; _logger.LogWarning(ex, "Error processing quote at row {RowNumber}", rowNumber); } } // Save quotes one-by-one to isolate database errors foreach (var (quoteRowNumber, quote, quoteNumber) in quotesToImport) { try { await _unitOfWork.Quotes.AddAsync(quote); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true) { result.Warnings.Add($"Row {quoteRowNumber}: Quote with number '{quoteNumber}' already exists in database (detected during save). Skipping."); result.ErrorCount++; _logger.LogWarning("Duplicate quote number '{QuoteNumber}' detected during save at row {RowNumber}", quoteNumber, quoteRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } catch (Exception ex) { result.Errors.Add($"Row {quoteRowNumber}: Database error - {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Error saving quote at row {RowNumber}", quoteRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } } _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.Success = false; _logger.LogError(ex, "Fatal error importing quotes"); } return result; } /// /// Imports job records from a CSV stream, resolving customer, status, and priority FKs by name. /// Unlike quote import, CustomerEmail is mandatory here — a job with no customer is not a valid /// business record and the row is rejected with an error (not downgraded to a prospect). /// Status and priority are resolved against their respective lookup tables; both default to /// "Pending" / "Normal" with a warning when the CSV value is unrecognised, keeping the import /// permissive for data migrated from systems that use different status labels. /// FinalPrice and QuotedPrice are both seeded from the same CSV FinalPrice column because /// historic imported jobs typically carry only one agreed price figure. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own the imported records. public async Task ImportJobsAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} jobs for company {CompanyId}", records.Count, companyId); // Get all existing jobs for duplicate detection var existingJobs = await _unitOfWork.Jobs.GetAllAsync(); var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)) .ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase); // Get customers for lookup — build two dictionaries so we can resolve by email // first and fall back to company name when the customer has no email on file. var customers = await _unitOfWork.Customers.GetAllAsync(); var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email)) .ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase); // Name fallback: keyed on CompanyName (commercial) or "First Last" (non-commercial). // TryAdd ensures that if two customers share the same name the first one wins and the // lookup warning will prompt the user to resolve the ambiguity manually. var customerByName = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var c in customers) { var name = !string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName.Trim() : $"{c.ContactFirstName} {c.ContactLastName}".Trim(); if (!string.IsNullOrEmpty(name)) customerByName.TryAdd(name, c); } // Get job statuses for lookup var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase); // Get job priorities for lookup var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase); var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>(); foreach (var record in records) { rowNumber++; try { // Validate required fields if (string.IsNullOrWhiteSpace(record.JobNumber)) { result.Errors.Add($"Row {rowNumber}: JobNumber is required."); result.ErrorCount++; continue; } // Check for duplicate job number in existing data if (existingJobNumbers.ContainsKey(record.JobNumber.ToUpper())) { result.Warnings.Add($"Row {rowNumber}: Job with number '{record.JobNumber}' already exists in database. Skipping."); result.SkippedCount++; continue; } // Check for duplicate job number within the import batch if (jobsToImport.Any(x => x.JobNumber.Equals(record.JobNumber, StringComparison.OrdinalIgnoreCase))) { result.Warnings.Add($"Row {rowNumber}: Duplicate job number '{record.JobNumber}' found in import file. Skipping."); result.SkippedCount++; continue; } // Resolve customer: try email first, then fall back to company name. // Both CustomerEmail and CustomerName may be absent; at least one is required. Customer? customer = null; var hasEmail = !string.IsNullOrWhiteSpace(record.CustomerEmail); var hasName = !string.IsNullOrWhiteSpace(record.CustomerName); if (!hasEmail && !hasName) { result.Errors.Add($"Row {rowNumber}: Either CustomerEmail or CustomerName is required to identify the customer."); result.ErrorCount++; continue; } if (hasEmail) customerByEmail.TryGetValue(record.CustomerEmail!.Trim().ToLower(), out customer); if (customer == null && hasName) { // Name fallback — warn so the user knows which method resolved the record customerByName.TryGetValue(record.CustomerName!.Trim(), out customer); if (customer != null) result.Warnings.Add($"Row {rowNumber}: Customer matched by name '{record.CustomerName}' because no email was provided or matched. Verify this is the correct customer."); } if (customer == null) { var lookupDetail = hasEmail ? $"email '{record.CustomerEmail}'" : $"name '{record.CustomerName}'"; result.Errors.Add($"Row {rowNumber}: Customer not found by {lookupDetail}."); result.ErrorCount++; continue; } // Resolve job status int jobStatusId; if (!jobStatusDict.TryGetValue(record.Status.ToUpper(), out var jobStatus)) { // Default to "Pending" if status not found jobStatus = jobStatusDict.Values.FirstOrDefault(js => js.StatusCode.Equals("PENDING", StringComparison.OrdinalIgnoreCase)); if (jobStatus == null) { result.Errors.Add($"Row {rowNumber}: Job status '{record.Status}' not found and no default 'Pending' status exists."); result.ErrorCount++; continue; } result.Warnings.Add($"Row {rowNumber}: Job status '{record.Status}' not found. Using 'Pending' as default."); } jobStatusId = jobStatus.Id; // Resolve job priority int jobPriorityId; if (!jobPriorityDict.TryGetValue(record.Priority.ToUpper(), out var jobPriority)) { // Default to "Normal" if priority not found jobPriority = jobPriorityDict.Values.FirstOrDefault(jp => jp.PriorityCode.Equals("NORMAL", StringComparison.OrdinalIgnoreCase)); if (jobPriority == null) { result.Errors.Add($"Row {rowNumber}: Job priority '{record.Priority}' not found and no default 'Normal' priority exists."); result.ErrorCount++; continue; } result.Warnings.Add($"Row {rowNumber}: Job priority '{record.Priority}' not found. Using 'Normal' as default."); } jobPriorityId = jobPriority.Id; // Create job entity var job = new Job { CompanyId = companyId, JobNumber = record.JobNumber.Trim(), CustomerId = customer.Id, JobStatusId = jobStatusId, JobPriorityId = jobPriorityId, ScheduledDate = record.ScheduledDate, DueDate = record.DueDate, FinalPrice = record.FinalPrice ?? 0, QuotedPrice = record.FinalPrice ?? 0, CustomerPO = record.CustomerPO?.Trim(), SpecialInstructions = record.SpecialInstructions?.Trim(), InternalNotes = record.Notes?.Trim(), Description = record.SpecialInstructions?.Trim() ?? "Imported job", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; jobsToImport.Add((rowNumber, job, record.JobNumber.Trim())); } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; _logger.LogWarning(ex, "Error processing job at row {RowNumber}", rowNumber); } } // Save jobs one-by-one to isolate database errors foreach (var (jobRowNumber, job, jobNumber) in jobsToImport) { try { await _unitOfWork.Jobs.AddAsync(job); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true) { result.Warnings.Add($"Row {jobRowNumber}: Job with number '{jobNumber}' already exists in database (detected during save). Skipping."); result.ErrorCount++; _logger.LogWarning("Duplicate job number '{JobNumber}' detected during save at row {RowNumber}", jobNumber, jobRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } catch (Exception ex) { result.Errors.Add($"Row {jobRowNumber}: Database error - {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Error saving job at row {RowNumber}", jobRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } } _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.Success = false; _logger.LogError(ex, "Fatal error importing jobs"); } return result; } /// /// Imports appointment records from a CSV stream, resolving customer, type, and status FKs. /// Customer linking is optional — appointments without a matching customer email are saved /// without a CustomerId rather than rejected, because walk-in or phone appointments are common. /// Appointment type is resolved by TypeCode first, then falls back to DisplayName matching /// so that human-readable type labels exported from older versions still map correctly. /// Status defaults to "Scheduled" when the supplied value is unrecognised. /// AppointmentNumber is the duplicate-detection key; the same two-layer check (DB + batch) used /// by other importers prevents re-import of already-stored records. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own the imported records. public async Task ImportAppointmentsAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} appointments for company {CompanyId}", records.Count, companyId); // Get all existing appointments for duplicate detection var existingAppointments = await _unitOfWork.Appointments.GetAllAsync(); var existingAppointmentNumbers = existingAppointments.Where(a => !string.IsNullOrEmpty(a.AppointmentNumber)) .ToDictionary(a => a.AppointmentNumber.ToUpper(), a => a, StringComparer.OrdinalIgnoreCase); // Get customers for lookup (optional for appointments) var customers = await _unitOfWork.Customers.GetAllAsync(); var customerDict = customers.Where(c => !string.IsNullOrEmpty(c.Email)) .ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase); // Get appointment types for lookup var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync(); var appointmentTypeDict = appointmentTypes.ToDictionary(at => at.TypeCode.ToUpper(), at => at, StringComparer.OrdinalIgnoreCase); // Get appointment statuses for lookup var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync(); var appointmentStatusDict = appointmentStatuses.ToDictionary(asl => asl.StatusCode.ToUpper(), asl => asl, StringComparer.OrdinalIgnoreCase); var appointmentsToImport = new List<(int RowNumber, Appointment Appointment, string AppointmentNumber)>(); foreach (var record in records) { rowNumber++; try { // Validate required fields if (string.IsNullOrWhiteSpace(record.AppointmentNumber)) { result.Errors.Add($"Row {rowNumber}: AppointmentNumber is required."); result.ErrorCount++; continue; } if (string.IsNullOrWhiteSpace(record.Title)) { result.Errors.Add($"Row {rowNumber}: Title is required."); result.ErrorCount++; continue; } // Check for duplicate appointment number in existing data if (existingAppointmentNumbers.ContainsKey(record.AppointmentNumber.ToUpper())) { result.Warnings.Add($"Row {rowNumber}: Appointment with number '{record.AppointmentNumber}' already exists in database. Skipping."); result.ErrorCount++; continue; } // Check for duplicate appointment number within the import batch if (appointmentsToImport.Any(x => x.AppointmentNumber.Equals(record.AppointmentNumber, StringComparison.OrdinalIgnoreCase))) { result.Warnings.Add($"Row {rowNumber}: Duplicate appointment number '{record.AppointmentNumber}' found in import file. Skipping."); result.ErrorCount++; continue; } // Resolve customer by email (optional) int? customerId = null; if (!string.IsNullOrEmpty(record.CustomerEmail)) { if (customerDict.TryGetValue(record.CustomerEmail.ToLower(), out var customer)) { customerId = customer.Id; } else { result.Warnings.Add($"Row {rowNumber}: Customer with email '{record.CustomerEmail}' not found. Creating appointment without customer."); } } // Resolve appointment type int appointmentTypeId; if (!appointmentTypeDict.TryGetValue(record.AppointmentType.ToUpper(), out var appointmentType)) { // Try to find by display name as fallback appointmentType = appointmentTypes.FirstOrDefault(at => at.DisplayName.Equals(record.AppointmentType, StringComparison.OrdinalIgnoreCase)); if (appointmentType == null) { result.Errors.Add($"Row {rowNumber}: Appointment type '{record.AppointmentType}' not found."); result.ErrorCount++; continue; } } appointmentTypeId = appointmentType.Id; // Resolve appointment status int appointmentStatusId; if (!appointmentStatusDict.TryGetValue(record.Status.ToUpper(), out var appointmentStatus)) { // Default to "Scheduled" if status not found appointmentStatus = appointmentStatusDict.Values.FirstOrDefault(asl => asl.StatusCode.Equals("SCHEDULED", StringComparison.OrdinalIgnoreCase)); if (appointmentStatus == null) { result.Errors.Add($"Row {rowNumber}: Appointment status '{record.Status}' not found and no default 'Scheduled' status exists."); result.ErrorCount++; continue; } result.Warnings.Add($"Row {rowNumber}: Appointment status '{record.Status}' not found. Using 'Scheduled' as default."); } appointmentStatusId = appointmentStatus.Id; // Create appointment entity var appointment = new Appointment { CompanyId = companyId, AppointmentNumber = record.AppointmentNumber.Trim(), CustomerId = customerId, AppointmentTypeId = appointmentTypeId, AppointmentStatusId = appointmentStatusId, Title = record.Title.Trim(), Description = record.Description?.Trim(), ScheduledStartTime = record.ScheduledStart, ScheduledEndTime = record.ScheduledEnd, Location = record.Location?.Trim(), Notes = record.Notes?.Trim(), CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; appointmentsToImport.Add((rowNumber, appointment, record.AppointmentNumber.Trim())); } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; _logger.LogWarning(ex, "Error processing appointment at row {RowNumber}", rowNumber); } } // Save appointments one-by-one to isolate database errors foreach (var (appointmentRowNumber, appointment, appointmentNumber) in appointmentsToImport) { try { await _unitOfWork.Appointments.AddAsync(appointment); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true) { result.Warnings.Add($"Row {appointmentRowNumber}: Appointment with number '{appointmentNumber}' already exists in database (detected during save). Skipping."); result.ErrorCount++; _logger.LogWarning("Duplicate appointment number '{AppointmentNumber}' detected during save at row {RowNumber}", appointmentNumber, appointmentRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } catch (Exception ex) { result.Errors.Add($"Row {appointmentRowNumber}: Database error - {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Error saving appointment at row {RowNumber}", appointmentRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } } _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.Success = false; _logger.LogError(ex, "Fatal error importing appointments"); } return result; } /// /// Imports equipment records from a CSV stream, parsing the EquipmentStatus enum from the /// human-readable string in the CSV. Spaces are stripped from the status string before parsing /// so that values like "Needs Maintenance" (two words) are accepted alongside the enum name /// "NeedsMaintenance". Duplicate detection uses EquipmentName as the natural key because /// equipment records rarely have an external unique identifier that users would know. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own the imported records. public async Task ImportEquipmentAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} equipment records for company {CompanyId}", records.Count, companyId); // Get all existing equipment for duplicate detection var existingEquipment = await _unitOfWork.Equipment.GetAllAsync(); var existingEquipmentNames = existingEquipment.Where(e => !string.IsNullOrEmpty(e.EquipmentName)) .ToDictionary(e => e.EquipmentName.ToUpper(), e => e, StringComparer.OrdinalIgnoreCase); var equipmentToImport = new List<(int RowNumber, Equipment Equipment, string EquipmentName)>(); foreach (var record in records) { rowNumber++; try { // Validate required fields if (string.IsNullOrWhiteSpace(record.EquipmentName)) { result.Errors.Add($"Row {rowNumber}: EquipmentName is required."); result.ErrorCount++; continue; } if (string.IsNullOrWhiteSpace(record.EquipmentType)) { result.Errors.Add($"Row {rowNumber}: EquipmentType is required."); result.ErrorCount++; continue; } // Check for duplicate equipment name in existing data if (existingEquipmentNames.ContainsKey(record.EquipmentName.ToUpper())) { result.Warnings.Add($"Row {rowNumber}: Equipment with name '{record.EquipmentName}' already exists in database. Skipping."); result.ErrorCount++; continue; } // Check for duplicate equipment name within the import batch if (equipmentToImport.Any(x => x.EquipmentName.Equals(record.EquipmentName, StringComparison.OrdinalIgnoreCase))) { result.Warnings.Add($"Row {rowNumber}: Duplicate equipment name '{record.EquipmentName}' found in import file. Skipping."); result.ErrorCount++; continue; } // Parse equipment status EquipmentStatus status = EquipmentStatus.Operational; if (!string.IsNullOrEmpty(record.Status)) { if (!Enum.TryParse(record.Status.Replace(" ", ""), true, out status)) { result.Warnings.Add($"Row {rowNumber}: Equipment status '{record.Status}' not recognized. Using 'Operational' as default."); status = EquipmentStatus.Operational; } } // Create equipment entity var equipment = new Equipment { CompanyId = companyId, EquipmentName = record.EquipmentName.Trim(), EquipmentNumber = record.EquipmentNumber?.Trim(), EquipmentType = record.EquipmentType.Trim(), Manufacturer = record.Manufacturer?.Trim(), Model = record.Model?.Trim(), SerialNumber = record.SerialNumber?.Trim(), PurchaseDate = record.PurchaseDate, PurchasePrice = record.PurchasePrice ?? 0, WarrantyExpiration = record.WarrantyExpiration, Location = record.Location?.Trim(), RecommendedMaintenanceIntervalDays = record.RecommendedMaintenanceIntervalDays ?? 0, Status = status, Notes = record.Notes?.Trim(), IsActive = record.IsActive ?? true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; equipmentToImport.Add((rowNumber, equipment, record.EquipmentName.Trim())); } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; _logger.LogWarning(ex, "Error processing equipment at row {RowNumber}", rowNumber); } } // Save equipment one-by-one to isolate database errors foreach (var (equipmentRowNumber, equipment, equipmentName) in equipmentToImport) { try { await _unitOfWork.Equipment.AddAsync(equipment); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true) { result.Warnings.Add($"Row {equipmentRowNumber}: Equipment with name '{equipmentName}' already exists in database (detected during save). Skipping."); result.ErrorCount++; _logger.LogWarning("Duplicate equipment name '{EquipmentName}' detected during save at row {RowNumber}", equipmentName, equipmentRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } catch (Exception ex) { result.Errors.Add($"Row {equipmentRowNumber}: Database error - {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Error saving equipment at row {RowNumber}", equipmentRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } } _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.Success = false; _logger.LogError(ex, "Fatal error importing equipment"); } return result; } /// /// Imports maintenance records from a CSV stream, resolving equipment by name and parsing /// and from string values. /// Unlike most other importers, maintenance records have no natural unique key (a piece of /// equipment can have many scheduled records with the same type and date), so no duplicate /// detection is performed — callers should ensure they do not re-upload the same file twice. /// TotalCost defaults to LaborCost + PartsCost when not explicitly provided, mirroring what /// the UI does when an admin creates a record manually. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own the imported records. public async Task ImportMaintenanceAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} maintenance records for company {CompanyId}", records.Count, companyId); // Get all equipment for lookup var equipment = await _unitOfWork.Equipment.GetAllAsync(); var equipmentDict = equipment.Where(e => !string.IsNullOrEmpty(e.EquipmentName)) .ToDictionary(e => e.EquipmentName.ToUpper(), e => e, StringComparer.OrdinalIgnoreCase); var maintenanceToImport = new List<(int RowNumber, MaintenanceRecord Maintenance)>(); foreach (var record in records) { rowNumber++; try { // Validate required fields if (string.IsNullOrWhiteSpace(record.EquipmentName)) { result.Errors.Add($"Row {rowNumber}: EquipmentName is required."); result.ErrorCount++; continue; } if (string.IsNullOrWhiteSpace(record.MaintenanceType)) { result.Errors.Add($"Row {rowNumber}: MaintenanceType is required."); result.ErrorCount++; continue; } if (string.IsNullOrWhiteSpace(record.Description)) { result.Errors.Add($"Row {rowNumber}: Description is required."); result.ErrorCount++; continue; } // Resolve equipment by name if (!equipmentDict.TryGetValue(record.EquipmentName.ToUpper(), out var equipmentEntity)) { result.Errors.Add($"Row {rowNumber}: Equipment with name '{record.EquipmentName}' not found."); result.ErrorCount++; continue; } // Parse maintenance status MaintenanceStatus status = MaintenanceStatus.Scheduled; if (!string.IsNullOrEmpty(record.Status)) { if (!Enum.TryParse(record.Status.Replace(" ", ""), true, out status)) { result.Warnings.Add($"Row {rowNumber}: Maintenance status '{record.Status}' not recognized. Using 'Scheduled' as default."); status = MaintenanceStatus.Scheduled; } } // Parse maintenance priority MaintenancePriority priority = MaintenancePriority.Normal; if (!string.IsNullOrEmpty(record.Priority)) { if (!Enum.TryParse(record.Priority.Replace(" ", ""), true, out priority)) { result.Warnings.Add($"Row {rowNumber}: Maintenance priority '{record.Priority}' not recognized. Using 'Normal' as default."); priority = MaintenancePriority.Normal; } } // Create maintenance record entity var maintenance = new MaintenanceRecord { CompanyId = companyId, EquipmentId = equipmentEntity.Id, MaintenanceType = record.MaintenanceType.Trim(), Status = status, Priority = priority, ScheduledDate = record.ScheduledDate, CompletedDate = record.CompletedDate, Description = record.Description.Trim(), WorkPerformed = record.WorkPerformed?.Trim(), LaborCost = record.LaborCost ?? 0, PartsCost = record.PartsCost ?? 0, TotalCost = record.TotalCost ?? (record.LaborCost ?? 0) + (record.PartsCost ?? 0), Notes = record.Notes?.Trim(), CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; maintenanceToImport.Add((rowNumber, maintenance)); } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; _logger.LogWarning(ex, "Error processing maintenance record at row {RowNumber}", rowNumber); } } // Save maintenance records one-by-one to isolate database errors foreach (var (maintenanceRowNumber, maintenance) in maintenanceToImport) { try { await _unitOfWork.MaintenanceRecords.AddAsync(maintenance); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } catch (Exception ex) { result.Errors.Add($"Row {maintenanceRowNumber}: Database error - {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Error saving maintenance record at row {RowNumber}", maintenanceRowNumber); // Detach failed entity so it doesn't contaminate the next row's save _unitOfWork.ClearChangeTracker(); } } _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.Success = false; _logger.LogError(ex, "Fatal error importing maintenance records"); } return result; } #endregion #region Vendor Import /// /// Generates a downloadable CSV template with a single example vendor row. /// The example includes all optional contact and financial fields so users understand /// the full breadth of data they can supply, including AccountNumber and CreditLimit /// which are needed for the Accounts Payable module to function correctly. /// public byte[] GenerateVendorTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new VendorImportDto { CompanyName = "Acme Powder Supply Co.", ContactName = "Jane Smith", Email = "jane@acmepowder.com", Phone = "555-9000", Address = "456 Industrial Blvd", City = "Chicago", State = "IL", ZipCode = "60601", Country = "USA", Website = "https://www.acmepowder.com", AccountNumber = "ACC-001", TaxId = "98-7654321", PaymentTerms = "Net 30", CreditLimit = 10000, IsPreferred = true, IsActive = true, Notes = "Primary powder supplier" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Imports vendors from a CSV stream using an upsert strategy keyed on CompanyName. /// Unlike the customer and inventory importers which skip duplicates, vendor import /// intentionally updates existing records so that re-exporting from QuickBooks and /// re-importing is a safe, idempotent refresh operation. Only non-null CSV fields /// overwrite existing values — a blank CSV cell never clears a field that was previously set. /// CompanyName is passed through because accounting software /// frequently exports names wrapped in literal quote characters. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own newly inserted vendor records. public async Task ImportVendorsAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} vendors for company {CompanyId}", records.Count, companyId); // Load existing vendors for upsert matching var existingVendors = await _unitOfWork.Vendors.GetAllAsync(); var vendorDict = existingVendors .Where(v => !string.IsNullOrEmpty(v.CompanyName)) .GroupBy(v => v.CompanyName.Trim().ToUpperInvariant()) .ToDictionary(g => g.Key, g => g.First()); foreach (var record in records) { rowNumber++; try { // Strip any literal quote characters that QB/Excel may wrap around field values var cleanCompanyName = StripQuotes(record.CompanyName); if (string.IsNullOrWhiteSpace(cleanCompanyName)) { result.Errors.Add($"Row {rowNumber}: CompanyName is required."); result.ErrorCount++; continue; } var key = cleanCompanyName.ToUpperInvariant(); var now = DateTime.UtcNow; if (vendorDict.TryGetValue(key, out var existing)) { // Update existing.ContactName = StripQuotes(record.ContactName) ?? existing.ContactName; existing.Email = record.Email ?? existing.Email; existing.Phone = record.Phone ?? existing.Phone; existing.Address = record.Address ?? existing.Address; existing.City = record.City ?? existing.City; existing.State = record.State ?? existing.State; existing.ZipCode = record.ZipCode ?? existing.ZipCode; existing.Country = record.Country ?? existing.Country; existing.Website = record.Website ?? existing.Website; existing.AccountNumber = record.AccountNumber ?? existing.AccountNumber; existing.TaxId = record.TaxId ?? existing.TaxId; existing.PaymentTerms = record.PaymentTerms ?? existing.PaymentTerms; if (record.CreditLimit.HasValue) existing.CreditLimit = record.CreditLimit; if (record.IsPreferred.HasValue) existing.IsPreferred = record.IsPreferred.Value; if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value; existing.Notes = record.Notes ?? existing.Notes; existing.UpdatedAt = now; await _unitOfWork.CompleteAsync(); result.Warnings.Add($"Row {rowNumber}: Updated existing vendor '{cleanCompanyName}'."); result.SuccessCount++; } else { var vendor = new Core.Entities.Vendor { CompanyId = companyId, CompanyName = cleanCompanyName, ContactName = StripQuotes(record.ContactName), Email = record.Email, Phone = record.Phone, Address = record.Address, City = record.City, State = record.State, ZipCode = record.ZipCode, Country = record.Country ?? "USA", Website = record.Website, AccountNumber = record.AccountNumber, TaxId = record.TaxId, PaymentTerms = record.PaymentTerms, CreditLimit = record.CreditLimit, IsPreferred = record.IsPreferred ?? false, IsActive = record.IsActive ?? true, Notes = record.Notes, CreatedAt = now, UpdatedAt = now }; await _unitOfWork.Vendors.AddAsync(vendor); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Error saving vendor at row {RowNumber}", rowNumber); } } _logger.LogInformation("Vendor import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.Success = false; _logger.LogError(ex, "Fatal error importing vendors"); } return result; } #endregion #region Prep Service Import /// /// Generates a downloadable CSV template with three example prep service rows covering the most /// common surface-preparation processes (Sandblasting, Chemical Stripping, Hand Sanding). /// DisplayOrder is shown explicitly so users understand they can control the order in which /// prep services appear in the quote/job wizard. /// public byte[] GeneratePrepServiceTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new PrepServiceImportDto { ServiceName = "Sandblasting", Description = "Abrasive blasting to remove rust, paint, and scale", DisplayOrder = 10, IsActive = true }); csv.NextRecord(); csv.WriteRecord(new PrepServiceImportDto { ServiceName = "Chemical Stripping", Description = "Chemical process to remove existing coatings", DisplayOrder = 20, IsActive = true }); csv.NextRecord(); csv.WriteRecord(new PrepServiceImportDto { ServiceName = "Hand Sanding", Description = "Manual sanding for surface preparation", DisplayOrder = 30, IsActive = true }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Imports prep services from a CSV stream using an upsert strategy keyed on ServiceName. /// Upsert behaviour (update if exists, insert if new) was chosen because prep services are /// typically a small, stable list — being able to re-import to update descriptions and display /// order without manual deduplication is more valuable than strict insert-only protection. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own newly inserted service records. public async Task ImportPrepServicesAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} prep services for company {CompanyId}", records.Count, companyId); // Load existing services for upsert matching by name var existingServices = await _unitOfWork.PrepServices.GetAllAsync(); var serviceDict = existingServices .Where(s => !string.IsNullOrEmpty(s.ServiceName)) .GroupBy(s => s.ServiceName.Trim().ToUpperInvariant()) .ToDictionary(g => g.Key, g => g.First()); foreach (var record in records) { rowNumber++; try { if (string.IsNullOrWhiteSpace(record.ServiceName)) { result.Errors.Add($"Row {rowNumber}: ServiceName is required."); result.ErrorCount++; continue; } var key = record.ServiceName.Trim().ToUpperInvariant(); var now = DateTime.UtcNow; if (serviceDict.TryGetValue(key, out var existing)) { // Update existing.Description = record.Description ?? existing.Description; if (record.DisplayOrder.HasValue) existing.DisplayOrder = record.DisplayOrder.Value; if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value; existing.UpdatedAt = now; await _unitOfWork.CompleteAsync(); result.Warnings.Add($"Row {rowNumber}: Updated existing prep service '{record.ServiceName}'."); result.SuccessCount++; } else { var service = new Core.Entities.PrepService { CompanyId = companyId, ServiceName = record.ServiceName.Trim(), Description = record.Description, DisplayOrder = record.DisplayOrder ?? 0, IsActive = record.IsActive ?? true, CreatedAt = now, UpdatedAt = now }; await _unitOfWork.PrepServices.AddAsync(service); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Error saving prep service at row {RowNumber}", rowNumber); } } _logger.LogInformation("Prep service import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.Success = false; _logger.LogError(ex, "Fatal error importing prep services"); } return result; } #endregion #region Helper Methods /// /// Resolves a slash-delimited category path (e.g. "Automotive/Wheels") to a leaf category ID, /// creating any missing parent or child nodes in the process. Each path segment is resolved from /// left to right so that "Automotive" is created (or found) before "Wheels" is attached to it. /// A per-import dictionary cache prevents redundant DB round-trips when many rows share the same /// category path — the cache stores partial paths too (e.g. "Automotive") so sibling categories /// like "Automotive/Rims" reuse the already-resolved parent without hitting the database again. /// /// Slash-delimited path string, e.g. "Metal/Automotive/Wheels". /// Tenant company used when creating new category nodes. /// Shared mutable dictionary mapping path strings to category IDs for this import session. /// The database ID of the leaf (deepest) category in the path. private async Task ResolveOrCreateCategoryAsync(string categoryPath, int companyId, Dictionary cache) { // Check cache first if (cache.TryGetValue(categoryPath, out int cachedId)) { return cachedId; } var parts = categoryPath.Split('/', StringSplitOptions.RemoveEmptyEntries); int? parentId = null; string currentPath = ""; for (int i = 0; i < parts.Length; i++) { var categoryName = parts[i].Trim(); currentPath = i == 0 ? categoryName : $"{currentPath}/{categoryName}"; // Check cache for this level if (cache.TryGetValue(currentPath, out int levelId)) { parentId = levelId; continue; } // Try to find existing category var existingCategories = await _unitOfWork.CatalogCategories.FindAsync( c => c.Name == categoryName && c.ParentCategoryId == parentId); var category = existingCategories.FirstOrDefault(); if (category == null) { // Create new category category = new CatalogCategory { CompanyId = companyId, Name = categoryName, ParentCategoryId = parentId, IsActive = true, DisplayOrder = 0, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await _unitOfWork.CatalogCategories.AddAsync(category); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Created new category: {CategoryName} (parent: {ParentId})", categoryName, parentId); } // Cache this category cache[currentPath] = category.Id; parentId = category.Id; } return parentId!.Value; } #endregion /// /// Generates a CSV template for expense imports with one sample row. /// public byte[] GenerateExpenseTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new ExpenseImportDto { ExpenseNumber = "", Date = DateTime.Today, VendorName = "Acme Powder Supply Co.", ExpenseAccountNumber = "6200", PaymentAccountNumber = "1000", JobNumber = "", PaymentMethod = "Check", Amount = 250.00m, Memo = "Monthly powder order" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Imports expenses from a CSV stream for the given company. /// Resolves ExpenseAccountNumber and PaymentAccountNumber against Account.AccountNumber. /// VendorName (optional) is matched against Vendor.CompanyName. /// JobNumber (optional) is matched against Job.JobNumber. /// ExpenseNumber is auto-generated in EXP-YYMM-#### format when the column is blank. /// /// Readable CSV stream with a header row. /// Tenant company that will own the imported records. public async Task ImportExpensesAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} expenses for company {CompanyId}", records.Count, companyId); // Build lookup dictionaries var accounts = await _unitOfWork.Accounts.GetAllAsync(); var accountByNumber = accounts .Where(a => !string.IsNullOrEmpty(a.AccountNumber)) .GroupBy(a => a.AccountNumber.Trim()) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var vendors = await _unitOfWork.Vendors.GetAllAsync(); var vendorByName = vendors .Where(v => !string.IsNullOrEmpty(v.CompanyName)) .GroupBy(v => v.CompanyName.Trim()) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var jobs = await _unitOfWork.Jobs.GetAllAsync(); var jobByNumber = jobs .Where(j => !string.IsNullOrEmpty(j.JobNumber)) .ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase); // Pre-load existing expense numbers for duplicate detection and auto-numbering var existingExpenses = await _unitOfWork.Expenses.GetAllAsync(); var existingExpenseNumbers = existingExpenses .Where(e => !string.IsNullOrEmpty(e.ExpenseNumber)) .Select(e => e.ExpenseNumber.ToUpperInvariant()) .ToHashSet(StringComparer.OrdinalIgnoreCase); // Track numbers generated within this batch to avoid same-batch collisions var batchNumbers = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var record in records) { rowNumber++; try { // Resolve expense account (required) var cleanExpenseAccount = StripQuotes(record.ExpenseAccountNumber)?.Trim() ?? ""; if (string.IsNullOrWhiteSpace(cleanExpenseAccount)) { result.Errors.Add($"Row {rowNumber}: ExpenseAccountNumber is required."); result.ErrorCount++; continue; } if (!accountByNumber.TryGetValue(cleanExpenseAccount, out var expenseAccount)) { result.Errors.Add($"Row {rowNumber}: Account number '{cleanExpenseAccount}' not found in Chart of Accounts."); result.ErrorCount++; continue; } // Resolve payment account (required) var cleanPaymentAccount = StripQuotes(record.PaymentAccountNumber)?.Trim() ?? ""; if (string.IsNullOrWhiteSpace(cleanPaymentAccount)) { result.Errors.Add($"Row {rowNumber}: PaymentAccountNumber is required."); result.ErrorCount++; continue; } if (!accountByNumber.TryGetValue(cleanPaymentAccount, out var paymentAccount)) { result.Errors.Add($"Row {rowNumber}: Payment account number '{cleanPaymentAccount}' not found in Chart of Accounts."); result.ErrorCount++; continue; } // Resolve vendor (optional) int? vendorId = null; var cleanVendorName = StripQuotes(record.VendorName)?.Trim(); if (!string.IsNullOrEmpty(cleanVendorName)) { if (vendorByName.TryGetValue(cleanVendorName, out var vendor)) vendorId = vendor.Id; else result.Warnings.Add($"Row {rowNumber}: Vendor '{cleanVendorName}' not found — expense will be saved without a vendor link."); } // Resolve job (optional) int? jobId = null; var cleanJobNumber = StripQuotes(record.JobNumber)?.Trim(); if (!string.IsNullOrEmpty(cleanJobNumber)) { if (jobByNumber.TryGetValue(cleanJobNumber, out var job)) jobId = job.Id; else result.Warnings.Add($"Row {rowNumber}: Job '{cleanJobNumber}' not found — expense will be saved without a job link."); } // Resolve PaymentMethod enum if (!Enum.TryParse(record.PaymentMethod?.Trim(), ignoreCase: true, out var paymentMethod)) { result.Warnings.Add($"Row {rowNumber}: Unknown PaymentMethod '{record.PaymentMethod}'. Defaulting to Cash."); paymentMethod = Core.Enums.PaymentMethod.Cash; } // Determine expense number — use supplied value or auto-generate string expenseNumber; var cleanExpenseNumber = StripQuotes(record.ExpenseNumber)?.Trim(); if (!string.IsNullOrEmpty(cleanExpenseNumber)) { if (existingExpenseNumbers.Contains(cleanExpenseNumber) || batchNumbers.Contains(cleanExpenseNumber)) { result.Warnings.Add($"Row {rowNumber}: Expense number '{cleanExpenseNumber}' already exists. Skipping."); result.SkippedCount++; continue; } expenseNumber = cleanExpenseNumber; } else { // Auto-generate EXP-YYMM-#### using the current date var prefix = $"EXP-{record.Date:yyMM}-"; var maxSeq = existingExpenseNumbers .Where(n => n.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .Select(n => int.TryParse(n[prefix.Length..], out var seq) ? seq : 0) .Concat(batchNumbers .Where(n => n.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .Select(n => int.TryParse(n[prefix.Length..], out var seq) ? seq : 0)) .DefaultIfEmpty(0) .Max(); expenseNumber = $"{prefix}{maxSeq + 1:D4}"; } batchNumbers.Add(expenseNumber); existingExpenseNumbers.Add(expenseNumber); var expense = new Core.Entities.Expense { CompanyId = companyId, ExpenseNumber = expenseNumber, Date = record.Date, VendorId = vendorId, ExpenseAccountId = expenseAccount.Id, PaymentAccountId = paymentAccount.Id, JobId = jobId, PaymentMethod = paymentMethod, Amount = record.Amount, Memo = StripQuotes(record.Memo), CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await _unitOfWork.Expenses.AddAsync(expense); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; _logger.LogWarning(ex, "Error processing expense at row {RowNumber}", rowNumber); _unitOfWork.ClearChangeTracker(); } } } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.ErrorCount++; _logger.LogError(ex, "Fatal error importing expenses for company {CompanyId}", companyId); } return result; } /// /// Generates a CSV template for Chart of Accounts imports with one sample row per common account type. /// public byte[] GenerateChartOfAccountsTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvHelper.CsvWriter(writer, System.Globalization.CultureInfo.InvariantCulture); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new ChartOfAccountsImportDto { AccountNumber = "1010", Name = "Checking Account", AccountType = "Asset", AccountSubType = "Checking", Description = "Primary operating checking account", OpeningBalance = 0, OpeningBalanceDate = null, IsActive = true }); csv.NextRecord(); csv.WriteRecord(new ChartOfAccountsImportDto { AccountNumber = "4010", Name = "Coating Revenue", AccountType = "Revenue", AccountSubType = "ServiceRevenue", Description = "Revenue from powder coating services", IsActive = true }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Imports Chart of Accounts entries from a CSV stream. Existing accounts matched by /// AccountNumber are updated; new ones are created. System accounts are never modified. /// AccountType and AccountSubType must be valid enum names. /// /// Readable CSV stream with a header row. /// Tenant company that will own the imported accounts. public async Task ImportChartOfAccountsAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); try { // Load existing accounts keyed by AccountNumber (case-insensitive) var existing = await _unitOfWork.Accounts.GetAllAsync(); var accountDict = existing .Where(a => !string.IsNullOrEmpty(a.AccountNumber)) .GroupBy(a => a.AccountNumber.Trim().ToUpper()) .ToDictionary(g => g.Key, g => g.First()); using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; int rowNumber = 1; foreach (var record in records) { rowNumber++; try { var cleanNumber = StripQuotes(record.AccountNumber)?.Trim(); var cleanName = StripQuotes(record.Name)?.Trim(); if (string.IsNullOrWhiteSpace(cleanNumber)) { result.Errors.Add($"Row {rowNumber}: AccountNumber is required."); result.ErrorCount++; continue; } if (string.IsNullOrWhiteSpace(cleanName)) { result.Errors.Add($"Row {rowNumber}: Name is required."); result.ErrorCount++; continue; } if (!Enum.TryParse(record.AccountType?.Trim(), true, out var accountType)) { result.Errors.Add($"Row {rowNumber}: Unknown AccountType '{record.AccountType}'. Valid values: {string.Join(", ", Enum.GetNames())}"); result.ErrorCount++; continue; } if (!Enum.TryParse(record.AccountSubType?.Trim(), true, out var accountSubType)) { result.Errors.Add($"Row {rowNumber}: Unknown AccountSubType '{record.AccountSubType}'. Valid values: {string.Join(", ", Enum.GetNames())}"); result.ErrorCount++; continue; } DateTime? openingBalanceDate = null; if (!string.IsNullOrWhiteSpace(record.OpeningBalanceDate) && DateTime.TryParse(record.OpeningBalanceDate, out var parsedDate)) openingBalanceDate = parsedDate; var key = cleanNumber.ToUpper(); if (accountDict.TryGetValue(key, out var acct)) { // Skip system accounts — never overwrite them if (acct.IsSystem) { result.Warnings.Add($"Row {rowNumber}: Account '{cleanNumber}' is a system account and cannot be modified. Skipping."); result.SkippedCount++; continue; } // Update acct.Name = cleanName; acct.AccountType = accountType; acct.AccountSubType = accountSubType; acct.Description = StripQuotes(record.Description); if (record.OpeningBalance.HasValue) acct.OpeningBalance = record.OpeningBalance.Value; if (openingBalanceDate.HasValue) acct.OpeningBalanceDate = openingBalanceDate; if (record.IsActive.HasValue) acct.IsActive = record.IsActive.Value; acct.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Accounts.UpdateAsync(acct); result.SuccessCount++; } else { // Create var newAccount = new Account { CompanyId = companyId, AccountNumber = cleanNumber, Name = cleanName, AccountType = accountType, AccountSubType = accountSubType, Description = StripQuotes(record.Description), OpeningBalance = record.OpeningBalance ?? 0, OpeningBalanceDate = openingBalanceDate, IsActive = record.IsActive ?? true, IsSystem = false, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await _unitOfWork.Accounts.AddAsync(newAccount); result.SuccessCount++; } } catch (Exception ex) { result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; } } await _unitOfWork.CompleteAsync(); _logger.LogInformation("Chart of accounts import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount); result.Success = result.SuccessCount > 0; } catch (Exception ex) { result.Errors.Add($"Fatal error: {ex.Message}"); result.ErrorCount++; result.Success = false; _logger.LogError(ex, "Fatal error importing chart of accounts for company {CompanyId}", companyId); } return result; } /// /// Strips a single matching pair of outer quote characters (double or single) from a field value. /// QuickBooks and several other accounting tools export text fields wrapped in literal quote /// characters — e.g. the company name column contains the string "Acme Corp" including /// the quotes. CsvHelper passes these through as-is when the field itself is not CSV-quoted, /// which means a naive string comparison would fail to detect a duplicate against the stored /// value "Acme Corp". Stripping before comparison and before writing to the entity keeps the /// data clean without requiring the user to pre-process the file. /// /// Raw string value as returned by CsvHelper, or null. /// The trimmed string with outer quote characters removed, or null if the input was null. private static string? StripQuotes(string? value) { if (value == null) return null; var trimmed = value.Trim(); // Strip matching outer double-quotes if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"') trimmed = trimmed[1..^1].Trim(); // Strip matching outer single-quotes else if (trimmed.Length >= 2 && trimmed[0] == '\'' && trimmed[^1] == '\'') trimmed = trimmed[1..^1].Trim(); return trimmed; } // ── Invoice Import ─────────────────────────────────────────────────────────── /// /// Returns a CSV template whose column headers match the native invoice export so that /// exported files can be re-imported without modification. /// public byte[] GenerateInvoiceTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); // Write a sample row so users know what values are expected csv.WriteRecord(new InvoiceImportDto { InvoiceNumber = "INV-2601-0001", CustomerEmail = "customer@example.com", CustomerName = "Acme Corp", JobNumber = "JOB-2601-0001", Status = "Draft", InvoiceDate = DateTime.Today, DueDate = DateTime.Today.AddDays(30), SubTotal = 500.00m, TaxPercent = 8.0m, TaxAmount = 40.00m, DiscountAmount = 0m, Total = 540.00m, AmountPaid = 0m, CustomerPO = "", Terms = "Net 30", Notes = "" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } /// /// Imports invoice headers from a CSV stream. Customers are resolved by CustomerEmail first /// then by CustomerName; rows without a matching customer are skipped with an error. /// InvoiceNumber is the unique key — existing invoices are updated; new ones are created. /// AmountPaid is stored directly (covers migrated invoices that already have payments). /// Line items are not part of the CSV format. /// /// Readable stream of CSV data (header row required). /// Tenant company that will own the imported records. public async Task ImportInvoicesAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} invoices for company {CompanyId}", records.Count, companyId); // Build lookup dictionaries for customers var customers = await _unitOfWork.Customers.GetAllAsync(); var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email)) .ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase); var customerByName = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var c in customers) { var displayName = !string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName.Trim() : $"{c.ContactFirstName} {c.ContactLastName}".Trim(); if (!string.IsNullOrEmpty(displayName)) customerByName.TryAdd(displayName, c); } // Build job lookup by job number (optional link) var jobs = await _unitOfWork.Jobs.GetAllAsync(); var jobByNumber = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)) .ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase); // Existing invoices keyed by InvoiceNumber for upsert logic var existingInvoices = await _unitOfWork.Invoices.GetAllAsync(ignoreQueryFilters: false); var invoiceByNumber = existingInvoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber)) .ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase); // Valid InvoiceStatus enum values for parsing var validStatuses = Enum.GetNames() .ToDictionary(n => n.ToLower(), n => Enum.Parse(n), StringComparer.OrdinalIgnoreCase); var inBatchNumbers = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var record in records) { rowNumber++; try { if (string.IsNullOrWhiteSpace(record.InvoiceNumber)) { result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required."); result.ErrorCount++; continue; } var invoiceNumber = record.InvoiceNumber.Trim(); if (!inBatchNumbers.Add(invoiceNumber)) { result.Warnings.Add($"Row {rowNumber}: Duplicate InvoiceNumber '{invoiceNumber}' in file. Skipping."); result.ErrorCount++; continue; } // Resolve customer — required for invoices Customer? customer = null; if (!string.IsNullOrWhiteSpace(record.CustomerEmail)) customerByEmail.TryGetValue(record.CustomerEmail.Trim().ToLower(), out customer); if (customer == null && !string.IsNullOrWhiteSpace(record.CustomerName)) { customerByName.TryGetValue(record.CustomerName.Trim(), out customer); if (customer != null && !string.IsNullOrWhiteSpace(record.CustomerEmail)) result.Warnings.Add($"Row {rowNumber}: Customer matched by name '{record.CustomerName}' (email not found)."); } if (customer == null) { var identifier = record.CustomerEmail ?? record.CustomerName ?? "(none)"; result.Errors.Add($"Row {rowNumber}: Customer '{identifier}' not found. Invoice requires a linked customer."); result.ErrorCount++; continue; } // Optional job link int? jobId = null; if (!string.IsNullOrWhiteSpace(record.JobNumber)) { if (jobByNumber.TryGetValue(record.JobNumber.Trim(), out var job)) jobId = job.Id; else result.Warnings.Add($"Row {rowNumber}: Job '{record.JobNumber}' not found — invoice will not be linked to a job."); } // Parse status if (!validStatuses.TryGetValue(record.Status?.Trim() ?? "", out var status)) { result.Warnings.Add($"Row {rowNumber}: Unknown status '{record.Status}' — defaulting to Draft."); status = InvoiceStatus.Draft; } if (invoiceByNumber.TryGetValue(invoiceNumber, out var existing)) { // Update existing invoice existing.CustomerId = customer.Id; existing.JobId = jobId; existing.Status = status; existing.InvoiceDate = record.InvoiceDate == default ? DateTime.UtcNow : record.InvoiceDate; existing.DueDate = record.DueDate; existing.SubTotal = record.SubTotal; existing.TaxPercent = record.TaxPercent; existing.TaxAmount = record.TaxAmount; existing.DiscountAmount = record.DiscountAmount; existing.Total = record.Total; existing.AmountPaid = record.AmountPaid; existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(); existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(); existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim(); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } else { // Create new invoice var invoice = new Core.Entities.Invoice { InvoiceNumber = invoiceNumber, CompanyId = companyId, CustomerId = customer.Id, JobId = jobId, Status = status, InvoiceDate = record.InvoiceDate == default ? DateTime.UtcNow : record.InvoiceDate, DueDate = record.DueDate, SubTotal = record.SubTotal, TaxPercent = record.TaxPercent, TaxAmount = record.TaxAmount, DiscountAmount = record.DiscountAmount, Total = record.Total, AmountPaid = record.AmountPaid, CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(), Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(), Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim() }; await _unitOfWork.Invoices.AddAsync(invoice); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } } catch (Exception ex) { _logger.LogError(ex, "Error importing invoice row {RowNumber} for company {CompanyId}", rowNumber, companyId); result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; } } result.Success = result.ErrorCount == 0 || result.SuccessCount > 0; return result; } catch (Exception ex) { _logger.LogError(ex, "Fatal error during invoice CSV import for company {CompanyId}", companyId); result.Errors.Add($"Fatal error: {ex.Message}"); return result; } } public byte[] GeneratePaymentTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new PaymentImportDto { InvoiceNumber = "INV-2601-0001", Amount = 250.00m, PaymentDate = DateTime.Today, PaymentMethod = "Check", Reference = "CHK-1234", Notes = "" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } public async Task ImportPaymentsAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} payments for company {CompanyId}", records.Count, companyId); // Build invoice lookup by number scoped to this company, including existing payments for dedup var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments); var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber)) .ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase); var validMethods = Enum.GetNames() .ToDictionary(n => n.ToLower(), n => Enum.Parse(n), StringComparer.OrdinalIgnoreCase); // Track how much this import is adding per invoice so we can recalculate AmountPaid correctly. // Using a running sum rather than += avoids double-counting when invoices were previously // imported with AmountPaid already set directly (no actual Payment records). var addedPerInvoice = new Dictionary(); foreach (var record in records) { rowNumber++; try { if (string.IsNullOrWhiteSpace(record.InvoiceNumber)) { result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required."); result.ErrorCount++; continue; } if (!invoiceByNumber.TryGetValue(record.InvoiceNumber.Trim(), out var invoice)) { result.Errors.Add($"Row {rowNumber}: Invoice '{record.InvoiceNumber}' not found."); result.ErrorCount++; continue; } // Dedup: skip if the same invoice already has a payment matching date + amount var paymentDate = record.PaymentDate == default ? DateTime.UtcNow.Date : record.PaymentDate.Date; var isDuplicate = invoice.Payments.Any(p => p.PaymentDate.Date == paymentDate && p.Amount == record.Amount); if (isDuplicate) { result.Warnings.Add($"Row {rowNumber}: Payment of {record.Amount:C} on {paymentDate:yyyy-MM-dd} already exists for invoice '{record.InvoiceNumber}' — skipped."); result.SkippedCount++; continue; } if (!validMethods.TryGetValue(record.PaymentMethod?.Trim() ?? "", out var method)) { result.Warnings.Add($"Row {rowNumber}: Unknown PaymentMethod '{record.PaymentMethod}' — defaulting to Cash."); method = PaymentMethod.Cash; } var payment = new Core.Entities.Payment { InvoiceId = invoice.Id, CompanyId = companyId, Amount = record.Amount, PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc), PaymentMethod = method, Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(), Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim() }; await _unitOfWork.Payments.AddAsync(payment); await _unitOfWork.CompleteAsync(); addedPerInvoice.TryGetValue(invoice.Id, out var soFar); addedPerInvoice[invoice.Id] = soFar + record.Amount; result.SuccessCount++; } catch (Exception ex) { _logger.LogError(ex, "Error importing payment row {RowNumber} for company {CompanyId}", rowNumber, companyId); result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; } } // Recalculate AmountPaid for affected invoices from the actual sum of all their payments. // This prevents double-counting when invoices were previously imported with AmountPaid // already set directly (without corresponding Payment records). foreach (var (invoiceId, _) in addedPerInvoice) { var inv = invoices.First(i => i.Id == invoiceId); var allPayments = await _unitOfWork.Payments.FindAsync(p => p.InvoiceId == invoiceId && !p.IsDeleted); inv.AmountPaid = allPayments.Sum(p => p.Amount); if (inv.AmountPaid >= inv.Total) inv.Status = InvoiceStatus.Paid; else if (inv.AmountPaid > 0) inv.Status = InvoiceStatus.PartiallyPaid; else inv.Status = InvoiceStatus.Sent; await _unitOfWork.CompleteAsync(); } result.Success = result.ErrorCount == 0 || result.SuccessCount > 0; return result; } catch (Exception ex) { _logger.LogError(ex, "Fatal error during payment CSV import for company {CompanyId}", companyId); result.Errors.Add($"Fatal error: {ex.Message}"); return result; } } public byte[] GeneratePurchaseOrderTemplate() { using var memoryStream = new MemoryStream(); using var writer = new StreamWriter(memoryStream); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); csv.WriteHeader(); csv.NextRecord(); csv.WriteRecord(new PurchaseOrderImportDto { PoNumber = "PO-2601-0001", Vendor = "Acme Powder Supply Co.", Status = "Submitted", OrderDate = DateTime.Today, ExpectedDeliveryDate = DateTime.Today.AddDays(7), ReceivedDate = null, SubTotal = 500.00m, ShippingCost = 25.00m, TotalAmount = 525.00m, Notes = "" }); csv.NextRecord(); writer.Flush(); return memoryStream.ToArray(); } public async Task ImportPurchaseOrdersAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); var rowNumber = 0; try { using var reader = new StreamReader(csvStream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); var records = csv.GetRecords().ToList(); result.TotalRows = records.Count; _logger.LogInformation("Starting import of {Count} purchase orders for company {CompanyId}", records.Count, companyId); // Build vendor lookup by company name var vendors = await _unitOfWork.Vendors.GetAllAsync(); var vendorByName = vendors.Where(v => !string.IsNullOrEmpty(v.CompanyName)) .ToDictionary(v => v.CompanyName.Trim(), v => v, StringComparer.OrdinalIgnoreCase); // Existing POs for upsert var existingPos = await _unitOfWork.PurchaseOrders.GetAllAsync(); var poByNumber = existingPos.Where(p => !string.IsNullOrEmpty(p.PoNumber)) .ToDictionary(p => p.PoNumber.Trim(), p => p, StringComparer.OrdinalIgnoreCase); var validStatuses = Enum.GetNames() .ToDictionary(n => n.ToLower(), n => Enum.Parse(n), StringComparer.OrdinalIgnoreCase); var inBatchNumbers = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var record in records) { rowNumber++; try { if (string.IsNullOrWhiteSpace(record.PoNumber)) { result.Errors.Add($"Row {rowNumber}: PoNumber is required."); result.ErrorCount++; continue; } var poNumber = record.PoNumber.Trim(); if (!inBatchNumbers.Add(poNumber)) { result.Warnings.Add($"Row {rowNumber}: Duplicate PoNumber '{poNumber}' in file — skipped."); result.SkippedCount++; continue; } if (string.IsNullOrWhiteSpace(record.Vendor)) { result.Errors.Add($"Row {rowNumber}: Vendor is required."); result.ErrorCount++; continue; } if (!vendorByName.TryGetValue(record.Vendor.Trim(), out var vendor)) { result.Errors.Add($"Row {rowNumber}: Vendor '{record.Vendor}' not found."); result.ErrorCount++; continue; } if (!validStatuses.TryGetValue(record.Status?.Trim() ?? "", out var status)) { result.Warnings.Add($"Row {rowNumber}: Unknown status '{record.Status}' — defaulting to Draft."); status = PurchaseOrderStatus.Draft; } if (poByNumber.TryGetValue(poNumber, out var existing)) { existing.VendorId = vendor.Id; existing.Status = status; existing.OrderDate = record.OrderDate == default ? DateTime.UtcNow : record.OrderDate; existing.ExpectedDeliveryDate = record.ExpectedDeliveryDate; existing.ReceivedDate = record.ReceivedDate; existing.SubTotal = record.SubTotal; existing.ShippingCost = record.ShippingCost; existing.TotalAmount = record.TotalAmount; existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim(); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } else { var po = new Core.Entities.PurchaseOrder { PoNumber = poNumber, CompanyId = companyId, VendorId = vendor.Id, Status = status, OrderDate = record.OrderDate == default ? DateTime.UtcNow : record.OrderDate, ExpectedDeliveryDate = record.ExpectedDeliveryDate, ReceivedDate = record.ReceivedDate, SubTotal = record.SubTotal, ShippingCost = record.ShippingCost, TotalAmount = record.TotalAmount, Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim() }; await _unitOfWork.PurchaseOrders.AddAsync(po); await _unitOfWork.CompleteAsync(); result.SuccessCount++; } } catch (Exception ex) { _logger.LogError(ex, "Error importing purchase order row {RowNumber} for company {CompanyId}", rowNumber, companyId); result.Errors.Add($"Row {rowNumber}: {ex.Message}"); result.ErrorCount++; } } result.Success = result.ErrorCount == 0 || result.SuccessCount > 0; return result; } catch (Exception ex) { _logger.LogError(ex, "Fatal error during purchase order CSV import for company {CompanyId}", companyId); result.Errors.Add($"Fatal error: {ex.Message}"); return result; } } }