1a44133a63
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>
3344 lines
158 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|