Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/CsvImportService.cs
T
spouliot 1a44133a63 Remove ShopWorker entity and migrate worker identity to ApplicationUser
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:32:32 -04:00

3344 lines
158 KiB
C#

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<CsvImportService> _logger;
public CsvImportService(IUnitOfWork unitOfWork, ILogger<CsvImportService> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
#region Template Generation
/// <summary>
/// Generates a downloadable CSV template pre-populated with a single example customer row.
/// The template is produced using the same <see cref="CustomerImportDto"/> class that the
/// importer reads, so column names and data types are always in sync with the import logic.
/// </summary>
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<CustomerImportDto>();
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();
}
/// <summary>
/// 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.
/// </summary>
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<CatalogItemImportDto>();
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();
}
/// <summary>
/// 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.
/// </summary>
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<InventoryItemImportDto>();
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();
}
/// <summary>
/// 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.
/// </summary>
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<QuoteImportDto>();
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();
}
/// <summary>
/// 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.
/// </summary>
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<JobImportDto>();
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();
}
/// <summary>
/// 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.
/// </summary>
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<AppointmentImportDto>();
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();
}
/// <summary>
/// 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).
/// </summary>
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<EquipmentImportDto>();
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();
}
/// <summary>
/// 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.
/// </summary>
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<MaintenanceImportDto>();
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
/// <summary>
/// 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.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own the imported records.</param>
public async Task<CsvImportResultDto> 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<CustomerImportDto>().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;
}
/// <summary>
/// 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 <see cref="ResolveOrCreateCategoryAsync"/>, 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 (<paramref name="revenueAccountId"/>, <paramref name="cogsAccountId"/>)
/// are stamped on every item so that new catalog items can integrate with the AP/AR module without
/// manual post-import account assignment.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own the imported records.</param>
/// <param name="revenueAccountId">Optional revenue GL account to assign to every imported item.</param>
/// <param name="cogsAccountId">Optional COGS GL account to assign to every imported item.</param>
public async Task<CsvImportResultDto> 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<CatalogItemImportDto>().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<string, int>();
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own the imported records.</param>
/// <param name="inventoryAccountId">Optional inventory asset GL account stamped on every new item.</param>
/// <param name="cogsAccountId">Optional COGS GL account stamped on every new item.</param>
public async Task<CsvImportResultDto> 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<InventoryItemImportDto>().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<string, InventoryCategoryLookup>(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<string, string>(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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own the imported records.</param>
public async Task<CsvImportResultDto> 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<QuoteImportDto>().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<string, Customer>(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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own the imported records.</param>
public async Task<CsvImportResultDto> 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<JobImportDto>().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<string, Customer>(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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own the imported records.</param>
public async Task<CsvImportResultDto> 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<AppointmentImportDto>().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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own the imported records.</param>
public async Task<CsvImportResultDto> 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<EquipmentImportDto>().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<EquipmentStatus>(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;
}
/// <summary>
/// Imports maintenance records from a CSV stream, resolving equipment by name and parsing
/// <see cref="MaintenanceStatus"/> and <see cref="MaintenancePriority"/> 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.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own the imported records.</param>
public async Task<CsvImportResultDto> 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<MaintenanceImportDto>().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<MaintenanceStatus>(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<MaintenancePriority>(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
/// <summary>
/// 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.
/// </summary>
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<VendorImportDto>();
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();
}
/// <summary>
/// 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 <see cref="StripQuotes"/> because accounting software
/// frequently exports names wrapped in literal quote characters.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own newly inserted vendor records.</param>
public async Task<CsvImportResultDto> 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<VendorImportDto>().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
/// <summary>
/// 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.
/// </summary>
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<PrepServiceImportDto>();
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();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own newly inserted service records.</param>
public async Task<CsvImportResultDto> 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<PrepServiceImportDto>().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
/// <summary>
/// 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.
/// </summary>
/// <param name="categoryPath">Slash-delimited path string, e.g. "Metal/Automotive/Wheels".</param>
/// <param name="companyId">Tenant company used when creating new category nodes.</param>
/// <param name="cache">Shared mutable dictionary mapping path strings to category IDs for this import session.</param>
/// <returns>The database ID of the leaf (deepest) category in the path.</returns>
private async Task<int> ResolveOrCreateCategoryAsync(string categoryPath, int companyId, Dictionary<string, int> 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
/// <summary>
/// Generates a CSV template for expense imports with one sample row.
/// </summary>
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<ExpenseImportDto>();
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();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="csvStream">Readable CSV stream with a header row.</param>
/// <param name="companyId">Tenant company that will own the imported records.</param>
public async Task<CsvImportResultDto> 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<ExpenseImportDto>().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<string>(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<Core.Enums.PaymentMethod>(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;
}
/// <summary>
/// Generates a CSV template for Chart of Accounts imports with one sample row per common account type.
/// </summary>
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<ChartOfAccountsImportDto>();
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();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="csvStream">Readable CSV stream with a header row.</param>
/// <param name="companyId">Tenant company that will own the imported accounts.</param>
public async Task<CsvImportResultDto> 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<ChartOfAccountsImportDto>().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<AccountType>(record.AccountType?.Trim(), true, out var accountType))
{
result.Errors.Add($"Row {rowNumber}: Unknown AccountType '{record.AccountType}'. Valid values: {string.Join(", ", Enum.GetNames<AccountType>())}");
result.ErrorCount++;
continue;
}
if (!Enum.TryParse<AccountSubType>(record.AccountSubType?.Trim(), true, out var accountSubType))
{
result.Errors.Add($"Row {rowNumber}: Unknown AccountSubType '{record.AccountSubType}'. Valid values: {string.Join(", ", Enum.GetNames<AccountSubType>())}");
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;
}
/// <summary>
/// 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 <c>"Acme Corp"</c> 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.
/// </summary>
/// <param name="value">Raw string value as returned by CsvHelper, or null.</param>
/// <returns>The trimmed string with outer quote characters removed, or null if the input was null.</returns>
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 ───────────────────────────────────────────────────────────
/// <summary>
/// Returns a CSV template whose column headers match the native invoice export so that
/// exported files can be re-imported without modification.
/// </summary>
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<InvoiceImportDto>();
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();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own the imported records.</param>
public async Task<CsvImportResultDto> 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<InvoiceImportDto>().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<string, Customer>(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<InvoiceStatus>()
.ToDictionary(n => n.ToLower(), n => Enum.Parse<InvoiceStatus>(n), StringComparer.OrdinalIgnoreCase);
var inBatchNumbers = new HashSet<string>(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<PaymentImportDto>();
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<CsvImportResultDto> 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<PaymentImportDto>().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<PaymentMethod>()
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(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<int, decimal>();
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<PurchaseOrderImportDto>();
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<CsvImportResultDto> 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<PurchaseOrderImportDto>().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<PurchaseOrderStatus>()
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PurchaseOrderStatus>(n), StringComparer.OrdinalIgnoreCase);
var inBatchNumbers = new HashSet<string>(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;
}
}
}