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

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

4329 lines
195 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class ToolsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IQuickBooksIifService _quickBooksService;
private readonly ICsvImportService _csvImportService;
private readonly ITenantContext _tenantContext;
private readonly ILogger<ToolsController> _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountBalanceService _accountBalanceService;
private readonly PowderCoating.Application.Interfaces.IAuditService _auditService;
public ToolsController(
IUnitOfWork unitOfWork,
IQuickBooksIifService quickBooksService,
ICsvImportService csvImportService,
ITenantContext tenantContext,
ILogger<ToolsController> logger,
UserManager<ApplicationUser> userManager,
IAccountBalanceService accountBalanceService,
PowderCoating.Application.Interfaces.IAuditService auditService)
{
_unitOfWork = unitOfWork;
_quickBooksService = quickBooksService;
_csvImportService = csvImportService;
_tenantContext = tenantContext;
_logger = logger;
_userManager = userManager;
_accountBalanceService = accountBalanceService;
_auditService = auditService;
}
private Task LogImportAsync(string entityType, string fileName,
PowderCoating.Application.DTOs.QuickBooks.ImportResultDto r) =>
_auditService.LogAsync("Imported", entityType,
$"{fileName}: {r.Summary}",
new { fileName, r.TotalRecords, r.ImportedCount, r.UpdatedCount, r.SkippedCount, Errors = r.Errors.Count });
private Task LogCsvImportAsync(string entityType, string fileName,
PowderCoating.Application.DTOs.Import.CsvImportResultDto r) =>
_auditService.LogAsync("Imported", entityType,
$"{fileName}: {r.Summary}",
new { fileName, r.TotalRows, r.SuccessCount, r.ErrorCount, r.SkippedCount });
private Task LogExportAsync(string entityType, string description) =>
_auditService.LogAsync("Exported", entityType, description);
/// <summary>
/// Renders the Tools hub page, which exposes QuickBooks IIF/CSV import/export,
/// bulk CSV import/export, the worker randomizer wheel, and QuickBooks Online import tabs.
/// Redirects to Home if the user is not bound to a company (i.e., a SuperAdmin with no
/// active tenant context), because all tools are company-scoped.
/// </summary>
// GET: Tools
public async Task<IActionResult> Index()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company. Please contact support.";
return RedirectToAction("Index", "Home");
}
await PopulateImportAccountDropdownsAsync();
return View();
}
/// <summary>
/// Returns the current GL account lists as JSON so the import wizard can refresh dropdowns
/// without a full page reload. Called via AJAX when the catalog or inventory import card opens.
/// </summary>
// GET: Tools/GetImportAccounts
[HttpGet]
public async Task<IActionResult> GetImportAccounts()
{
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
var revenue = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
.OrderBy(a => a.AccountNumber)
.Select(a => new { value = a.Id.ToString(), text = $"{a.AccountNumber} {a.Name}" })
.Prepend(new { value = "", text = "— None —" });
var cogs = allAccounts
.Where(a => a.AccountType == AccountType.CostOfGoods && a.IsActive)
.OrderBy(a => a.AccountNumber)
.Select(a => new { value = a.Id.ToString(), text = $"{a.AccountNumber} {a.Name}" })
.Prepend(new { value = "", text = "— None —" });
var inventory = allAccounts
.Where(a => a.AccountType == AccountType.Asset && a.AccountSubType == AccountSubType.Inventory && a.IsActive)
.OrderBy(a => a.AccountNumber)
.Select(a => new { value = a.Id.ToString(), text = $"{a.AccountNumber} {a.Name}" })
.Prepend(new { value = "", text = "— None —" });
return Json(new
{
revenueAccounts = revenue,
cogsAccounts = cogs,
inventoryAccounts = inventory
});
}
/// <summary>
/// Populates ViewBag dropdowns used by CSV import forms that need an optional GL account
/// override (e.g., Revenue, COGS, Inventory Asset accounts). The "— None —" sentinel at
/// index 0 means "use whatever the import service defaults to" so callers can pass null.
/// Queries are company-scoped via global query filters; no ignoreQueryFilters needed here.
/// </summary>
private async Task PopulateImportAccountDropdownsAsync()
{
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
var revenueAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
revenueAccounts.Insert(0, new SelectListItem("— None —", ""));
ViewBag.RevenueAccounts = revenueAccounts;
var cogsAccounts = allAccounts
.Where(a => a.AccountType == AccountType.CostOfGoods && a.IsActive)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
cogsAccounts.Insert(0, new SelectListItem("— None —", ""));
ViewBag.CogsAccounts = cogsAccounts;
var inventoryAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Asset && a.AccountSubType == AccountSubType.Inventory && a.IsActive)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
inventoryAccounts.Insert(0, new SelectListItem("— None —", ""));
ViewBag.InventoryAccounts = inventoryAccounts;
}
/// <summary>
/// Exports all customers for the current company in QuickBooks IIF format (desktop) or
/// CSV format (online), delegating format-specific logic to <see cref="IQuickBooksIifService"/>.
/// The <paramref name="format"/> query-string parameter defaults to "desktop" (IIF / text/plain);
/// pass "online" to get a CSV suitable for QuickBooks Online's import wizard.
/// Returns the file directly on success; sets TempData["ErrorMessage"] and redirects on failure.
/// </summary>
// GET: Tools/ExportCustomers
[HttpGet]
public async Task<IActionResult> ExportCustomers(string format = "desktop")
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
_logger.LogInformation("User {UserName} exporting customers for company {CompanyId} in {Format} format",
User.Identity?.Name, companyId, format);
var result = format.ToLower() == "online"
? await _quickBooksService.ExportCustomersOnlineAsync(companyId.Value)
: await _quickBooksService.ExportCustomersAsync(companyId.Value);
if (!result.Success)
{
_logger.LogWarning("Export customers failed: {ErrorMessage}", result.ErrorMessage);
TempData["ErrorMessage"] = result.ErrorMessage;
return RedirectToAction(nameof(Index));
}
var contentType = format.ToLower() == "online" ? "text/csv" : "text/plain";
await LogExportAsync("Customers", $"QB {format} export");
return File(result.FileContent, contentType, result.FileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting customers");
TempData["ErrorMessage"] = "An error occurred while exporting customers.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports the company's service catalog in QuickBooks IIF (desktop) or CSV (online) format.
/// Mirrors <see cref="ExportCustomers"/> in structure; "online" produces a CSV that QuickBooks
/// Online can import as Products &amp; Services, while "desktop" produces an IIF file for
/// QuickBooks Desktop item import.
/// </summary>
// GET: Tools/ExportCatalogItems
[HttpGet]
public async Task<IActionResult> ExportCatalogItems(string format = "desktop")
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
_logger.LogInformation("User {UserName} exporting catalog items for company {CompanyId} in {Format} format",
User.Identity?.Name, companyId, format);
var result = format.ToLower() == "online"
? await _quickBooksService.ExportCatalogItemsOnlineAsync(companyId.Value)
: await _quickBooksService.ExportCatalogItemsAsync(companyId.Value);
if (!result.Success)
{
_logger.LogWarning("Export catalog items failed: {ErrorMessage}", result.ErrorMessage);
TempData["ErrorMessage"] = result.ErrorMessage;
return RedirectToAction(nameof(Index));
}
var contentType = format.ToLower() == "online" ? "text/csv" : "text/plain";
await LogExportAsync("CatalogItems", $"QB {format} export");
return File(result.FileContent, contentType, result.FileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting catalog items");
TempData["ErrorMessage"] = "An error occurred while exporting catalog items.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Accepts a QuickBooks-exported IIF or CSV customer file and upserts customers for the current
/// company via <see cref="IQuickBooksIifService"/>. Returns JSON so the page can display a
/// per-row error/warning accordion without a full page reload. The response always includes
/// row-level error details (severity, lineNumber, recordName, fieldName, errorMessage,
/// displayMessage) to let the user drill into any skipped or failed records.
/// </summary>
// POST: Tools/ImportCustomers
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportCustomers(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new
{
success = false,
message = "Your account is not associated with a company."
});
}
if (file == null || file.Length == 0)
{
return Json(new
{
success = false,
message = "No file provided or file is empty."
});
}
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing customers from {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
var result = await _quickBooksService.ImportCustomersAsync(file, companyId.Value, userId);
await LogImportAsync("Customers", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Success ? "Import completed successfully!" : "Import completed with errors.",
totalRecords = result.TotalRecords,
importedCount = result.ImportedCount,
updatedCount = result.UpdatedCount,
skippedCount = result.SkippedCount,
errors = result.Errors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
fieldName = e.FieldName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing customers");
return Json(new
{
success = false,
message = $"An error occurred during import: {ex.Message}"
});
}
}
/// <summary>
/// Exports the company's vendor list as a QuickBooks Desktop IIF file (text/plain).
/// Vendor export is desktop-only; there is no "online" variant because the QB Online vendor
/// CSV format is handled by the separate QBO import/export endpoints.
/// </summary>
// GET: Tools/ExportVendors
[HttpGet]
public async Task<IActionResult> ExportVendors()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
_logger.LogInformation("User {UserName} exporting vendors for company {CompanyId}",
User.Identity?.Name, companyId);
var result = await _quickBooksService.ExportVendorsAsync(companyId.Value);
if (!result.Success)
{
TempData["ErrorMessage"] = result.ErrorMessage;
return RedirectToAction(nameof(Index));
}
await LogExportAsync("Vendors", "QB IIF export");
return File(result.FileContent, "text/plain", result.FileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting vendors");
TempData["ErrorMessage"] = "An error occurred while exporting vendors.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Imports vendors from a QuickBooks-exported IIF or CSV file, upserting records for the
/// current company. Returns JSON with the same row-level detail structure as
/// <see cref="ImportCustomers"/> so the UI can render a consistent error accordion.
/// </summary>
// POST: Tools/ImportVendors
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportVendors(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing vendors from {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
var result = await _quickBooksService.ImportVendorsAsync(file, companyId.Value, userId);
await LogImportAsync("Vendors", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Success ? "Import completed successfully!" : "Import completed with errors.",
totalRecords = result.TotalRecords,
importedCount = result.ImportedCount,
updatedCount = result.UpdatedCount,
skippedCount = result.SkippedCount,
errors = result.Errors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
fieldName = e.FieldName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing vendors");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Imports historical invoices from a QuickBooks CSV export (e.g., Transaction List by Date).
/// After importing, calls <see cref="IAccountBalanceService.RecalculateAllAsync"/> to bring
/// every customer's <c>CurrentBalance</c> and every GL account balance into sync, because
/// bulk invoice imports can shift outstanding AR totals significantly.
/// </summary>
// POST: Tools/ImportQbInvoices
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQbInvoices(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing QB invoices from {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
var result = await _quickBooksService.ImportQbInvoicesFromCsvAsync(file, companyId.Value, userId);
await LogImportAsync("Invoices", file.FileName, result);
await _accountBalanceService.RecalculateAllAsync(companyId.Value);
return Json(new
{
success = result.Success,
message = result.Success ? "Invoice import completed!" : "Import completed with errors.",
totalRecords = result.TotalRecords,
importedCount = result.ImportedCount,
updatedCount = result.UpdatedCount,
skippedCount = result.SkippedCount,
errors = result.Errors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
fieldName = e.FieldName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing QB invoices");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Imports general-ledger transactions from a QuickBooks CSV export. Like
/// <see cref="ImportQbInvoices"/>, triggers a full account-balance recalculation after the
/// import because journal-entry style transactions directly affect GL account running totals.
/// </summary>
// POST: Tools/ImportQbTransactions
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQbTransactions(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing QB transactions from {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
var result = await _quickBooksService.ImportQbTransactionsFromCsvAsync(file, companyId.Value, userId);
await LogImportAsync("Transactions", file.FileName, result);
await _accountBalanceService.RecalculateAllAsync(companyId.Value);
return Json(new
{
success = result.Success,
message = result.Success ? "Transaction import completed!" : "Import completed with errors.",
totalRecords = result.TotalRecords,
importedCount = result.ImportedCount,
updatedCount = result.UpdatedCount,
skippedCount = result.SkippedCount,
errors = result.Errors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
fieldName = e.FieldName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing QB transactions");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Imports service catalog items from a QuickBooks IIF or CSV file, creating or updating
/// catalog items for the current company. No account-balance recalculation is needed because
/// catalog items carry pricing metadata but are not themselves financial transactions.
/// </summary>
// POST: Tools/ImportCatalogItems
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportCatalogItems(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new
{
success = false,
message = "Your account is not associated with a company."
});
}
if (file == null || file.Length == 0)
{
return Json(new
{
success = false,
message = "No file provided or file is empty."
});
}
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing catalog items from {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
var result = await _quickBooksService.ImportCatalogItemsAsync(file, companyId.Value, userId);
await LogImportAsync("CatalogItems", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Success ? "Import completed successfully!" : "Import completed with errors.",
totalRecords = result.TotalRecords,
importedCount = result.ImportedCount,
updatedCount = result.UpdatedCount,
skippedCount = result.SkippedCount,
errors = result.Errors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
fieldName = e.FieldName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing catalog items");
return Json(new
{
success = false,
message = $"An error occurred during import: {ex.Message}"
});
}
}
/// <summary>
/// Imports the Chart of Accounts from a QuickBooks CSV export, creating or updating GL accounts
/// for the current company. Triggers a full balance recalculation because account metadata
/// (type, subtype) directly affects how balances are aggregated in financial reports — a
/// changed account type can shift an amount from Assets to Expenses, for example.
/// </summary>
// POST: Tools/ImportChartOfAccounts
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportChartOfAccounts(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing Chart of Accounts from {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
var result = await _quickBooksService.ImportChartOfAccountsAsync(file, companyId.Value, userId);
await LogImportAsync("ChartOfAccounts", file.FileName, result);
await _accountBalanceService.RecalculateAllAsync(companyId.Value);
return Json(new
{
success = result.Success,
message = result.Success ? "Chart of Accounts imported successfully!" : "Import completed with errors.",
totalRecords = result.TotalRecords,
importedCount = result.ImportedCount,
updatedCount = result.UpdatedCount,
skippedCount = result.SkippedCount,
errors = result.Errors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing Chart of Accounts");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Imports vendor payments (bill payment checks/EFTs) from a QuickBooks CSV export and
/// reconciles them against existing vendor bills. Triggers account-balance recalculation
/// because payments reduce AP balances and affect cash/bank accounts. Use
/// <see cref="ImportQbBillsAndPayments"/> instead when the source file contains both bills
/// and their associated payments (e.g., a Vendor Balance Detail report), which avoids the
/// need to upload two separate files.
/// </summary>
// POST: Tools/ImportQbVendorPayments
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQbVendorPayments(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing QB vendor payments from {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
var result = await _quickBooksService.ImportQbVendorPaymentsAsync(file, companyId.Value, userId);
await LogImportAsync("VendorPayments", file.FileName, result);
await _accountBalanceService.RecalculateAllAsync(companyId.Value);
return Json(new
{
success = result.Success,
message = result.Success ? "Vendor payments imported successfully!" : "Import completed with errors.",
totalRecords = result.TotalRecords,
importedCount = result.ImportedCount,
updatedCount = result.UpdatedCount,
skippedCount = result.SkippedCount,
errors = result.Errors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
fieldName = e.FieldName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing QB vendor payments");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Combined action that processes a single QuickBooks Vendor Balance Detail CSV in two passes:
/// first importing all bills, then applying payments to those bills via FIFO matching.
/// The file is buffered into a <see cref="System.IO.MemoryStream"/> once so two independent
/// <see cref="IFormFile"/> wrappers can be created — one per service call — without re-reading
/// the original upload stream (which would be exhausted after the first pass). "Bill skipped"
/// messages from the payments pass are suppressed in the combined error list because every
/// bill-type row in the file will naturally be skipped by the payment importer; surfacing those
/// would create noise that masks real payment-matching failures.
/// </summary>
// POST: Tools/ImportQbBillsAndPayments
// Combined action: runs Bills pass then Vendor Payments pass on the same file (Vendor Balance Detail).
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQbBillsAndPayments(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing QB bills+payments from {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
// Read the file bytes once so we can pass two independent streams to the service
using var ms = new System.IO.MemoryStream();
await file.OpenReadStream().CopyToAsync(ms);
var fileBytes = ms.ToArray();
IFormFile MakeFormFile() => new FormFile(
new System.IO.MemoryStream(fileBytes), 0, fileBytes.Length,
file.Name, file.FileName)
{
Headers = file.Headers,
ContentType = file.ContentType
};
// Pass 1: Bills
var billsResult = await _quickBooksService.ImportQbBillsAsync(MakeFormFile(), companyId.Value, userId);
// Pass 2: Vendor Payments (FIFO against just-imported bills)
var pmtResult = await _quickBooksService.ImportQbVendorPaymentsAsync(MakeFormFile(), companyId.Value, userId);
await _accountBalanceService.RecalculateAllAsync(companyId.Value);
await LogImportAsync("Bills", file.FileName, billsResult);
await LogImportAsync("VendorPayments", file.FileName, pmtResult);
// Combine results — only surface non-"Bill" skipped messages from payments pass
// (the payments pass will generate "Bill skipped" messages for every Bill row, suppress those)
var filteredPmtErrors = pmtResult.Errors
.Where(e => !(e.Severity == "Skipped" && e.ErrorMessage != null &&
e.ErrorMessage.Contains("not a bill payment", StringComparison.OrdinalIgnoreCase)))
.ToList();
var allErrors = billsResult.Errors.Concat(filteredPmtErrors).ToList();
bool overallSuccess = billsResult.Success && pmtResult.Success;
return Json(new
{
success = overallSuccess,
message = overallSuccess
? $"Bills & payments imported successfully!"
: "Import completed with some errors — see details below.",
billsImported = billsResult.ImportedCount,
paymentsImported = pmtResult.ImportedCount,
totalRecords = billsResult.TotalRecords + pmtResult.TotalRecords,
importedCount = billsResult.ImportedCount + pmtResult.ImportedCount,
updatedCount = billsResult.UpdatedCount + pmtResult.UpdatedCount,
skippedCount = billsResult.SkippedCount + pmtResult.SkippedCount,
alreadyRecordedCount = billsResult.AlreadyRecordedCount + pmtResult.AlreadyRecordedCount,
errors = allErrors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
fieldName = e.FieldName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing QB bills and payments");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Imports vendor bills (AP invoices) from a QuickBooks CSV export. Use this when you have a
/// bills-only export file; use <see cref="ImportQbBillsAndPayments"/> when the same file also
/// contains payment rows. Triggers a full account-balance recalculation because each new bill
/// increases AP and may affect expense account balances.
/// </summary>
// POST: Tools/ImportQbBills
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQbBills(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing QB bills from {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
var result = await _quickBooksService.ImportQbBillsAsync(file, companyId.Value, userId);
await LogImportAsync("Bills", file.FileName, result);
await _accountBalanceService.RecalculateAllAsync(companyId.Value);
return Json(new
{
success = result.Success,
message = result.Success ? "Bills imported successfully!" : "Import completed with errors.",
totalRecords = result.TotalRecords,
importedCount = result.ImportedCount,
updatedCount = result.UpdatedCount,
skippedCount = result.SkippedCount,
errors = result.Errors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
fieldName = e.FieldName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing QB bills");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Imports inventory quantities and valuations from a QuickBooks Inventory Valuation Summary CSV.
/// Triggers a balance recalculation because imported on-hand quantities feed into the Inventory
/// Asset account balance used on the Balance Sheet report.
/// </summary>
// POST: Tools/ImportQbInventoryValuation
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQbInventoryValuation(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing QB inventory valuation from {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
var result = await _quickBooksService.ImportQbInventoryValuationAsync(file, companyId.Value, userId);
await LogImportAsync("InventoryValuation", file.FileName, result);
await _accountBalanceService.RecalculateAllAsync(companyId.Value);
return Json(new
{
success = result.Success,
message = result.Success ? "Inventory import completed successfully!" : "Import completed with errors.",
totalRecords = result.TotalRecords,
importedCount = result.ImportedCount,
updatedCount = result.UpdatedCount,
skippedCount = result.SkippedCount,
errors = result.Errors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing QB inventory valuation");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Returns the full names of all active users in the current company as a JSON array, used
/// by the shop-floor randomizer (spinning wheel) feature on the Tools page. Queries
/// <see cref="UserManager{TUser}"/> directly rather than going through <see cref="IUnitOfWork"/>
/// because Identity users are not exposed as a first-class repository; the filter by
/// <c>CompanyId</c> provides the multi-tenant isolation that global query filters would
/// normally enforce for other entity types.
/// </summary>
// GET: Tools/GetShopWorkers - Returns active company users for randomizer wheel
[HttpGet]
public async Task<IActionResult> GetShopWorkers()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var users = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync();
var workerNames = users
.Select(u => u.FullName)
.ToList();
return Json(new
{
success = true,
workers = workerNames
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching shop workers for randomizer");
return Json(new
{
success = false,
message = "Error loading shop workers"
});
}
}
#region CSV Bulk Import
/// <summary>
/// Downloads a blank CSV file pre-populated with the correct column headers for the native
/// (non-QuickBooks) customer bulk import. Timestamps the filename to prevent browser caching
/// when users download a fresh copy after a column layout change.
/// </summary>
// GET: Tools/DownloadCustomerTemplate
[HttpGet]
public IActionResult DownloadCustomerTemplate()
{
try
{
var csvContent = _csvImportService.GenerateCustomerTemplate();
var fileName = $"customer_import_template_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
return File(csvContent, "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating customer template");
TempData["ErrorMessage"] = "An error occurred while generating the template.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Downloads a blank CSV template for the native catalog-item bulk import, with headers
/// matching the columns expected by <see cref="CsvImportCatalogItems"/>.
/// </summary>
// GET: Tools/DownloadCatalogTemplate
[HttpGet]
public IActionResult DownloadCatalogTemplate()
{
try
{
var csvContent = _csvImportService.GenerateCatalogItemTemplate();
var fileName = $"catalog_import_template_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
return File(csvContent, "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating catalog template");
TempData["ErrorMessage"] = "An error occurred while generating the template.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Downloads a blank CSV template for the native inventory-item bulk import, with headers
/// matching the columns expected by <see cref="CsvImportInventoryItems"/>.
/// </summary>
// GET: Tools/DownloadInventoryTemplate
[HttpGet]
public IActionResult DownloadInventoryTemplate()
{
try
{
var csvContent = _csvImportService.GenerateInventoryItemTemplate();
var fileName = $"inventory_import_template_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
return File(csvContent, "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating inventory template");
TempData["ErrorMessage"] = "An error occurred while generating the template.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Accepts a native CSV file (not QuickBooks-formatted) and bulk-imports customers for the
/// current company via <see cref="ICsvImportService"/>. The service owns all upsert logic;
/// this action only validates the file type (must be .csv), resolves the company, and
/// returns a JSON result with per-row success/error/warning detail for the UI accordion.
/// </summary>
// POST: Tools/CsvImportCustomers
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportCustomers(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "Your account is not associated with a company." });
}
if (file == null || file.Length == 0)
{
return Json(new { success = false, message = "No file provided or file is empty." });
}
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
{
return Json(new { success = false, message = "Only CSV files are allowed." });
}
_logger.LogInformation("User {UserName} importing customers from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportCustomersAsync(stream, companyId.Value);
await LogCsvImportAsync("Customers", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing customers from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Bulk-imports service catalog items from a native CSV file. The optional
/// <paramref name="revenueAccountId"/> and <paramref name="cogsAccountId"/> parameters let
/// the user map all imported items to specific GL accounts in one pass, avoiding the need to
/// edit each item individually afterward. Passing null for either account defers to the
/// service's default mapping logic.
/// </summary>
// POST: Tools/CsvImportCatalogItems
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportCatalogItems(IFormFile file, int? revenueAccountId, int? cogsAccountId)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "Your account is not associated with a company." });
}
if (file == null || file.Length == 0)
{
return Json(new { success = false, message = "No file provided or file is empty." });
}
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
{
return Json(new { success = false, message = "Only CSV files are allowed." });
}
_logger.LogInformation("User {UserName} importing catalog items from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
// Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist.
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
.Select(a => a.Id).ToHashSet();
if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value))
revenueAccountId = null;
if (cogsAccountId.HasValue && !validAccountIds.Contains(cogsAccountId.Value))
cogsAccountId = null;
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportCatalogItemsAsync(stream, companyId.Value, revenueAccountId, cogsAccountId);
await LogCsvImportAsync("CatalogItems", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing catalog items from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Bulk-imports inventory items (powder coatings, consumables, etc.) from a native CSV file.
/// Like <see cref="CsvImportCatalogItems"/>, the optional GL account overrides
/// (<paramref name="inventoryAccountId"/> for the balance-sheet Inventory Asset account and
/// <paramref name="cogsAccountId"/> for the income-statement COGS account) let the user
/// pre-assign accounts during the import rather than editing each item afterward.
/// </summary>
// POST: Tools/CsvImportInventoryItems
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportInventoryItems(IFormFile file, int? inventoryAccountId, int? cogsAccountId)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "Your account is not associated with a company." });
}
if (file == null || file.Length == 0)
{
return Json(new { success = false, message = "No file provided or file is empty." });
}
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
{
return Json(new { success = false, message = "Only CSV files are allowed." });
}
_logger.LogInformation("User {UserName} importing inventory items from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
// Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist.
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
.Select(a => a.Id).ToHashSet();
if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value))
inventoryAccountId = null;
if (cogsAccountId.HasValue && !validAccountIds.Contains(cogsAccountId.Value))
cogsAccountId = null;
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportInventoryItemsAsync(stream, companyId.Value, inventoryAccountId, cogsAccountId);
await LogCsvImportAsync("InventoryItems", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing inventory items from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native quote bulk import, with headers matching
/// the columns expected by <see cref="CsvImportQuotes"/>.
/// </summary>
// GET: Tools/DownloadQuoteTemplate
[HttpGet]
public IActionResult DownloadQuoteTemplate()
{
var csvBytes = _csvImportService.GenerateQuoteTemplate();
return File(csvBytes, "text/csv", "quote_import_template.csv");
}
/// <summary>
/// Bulk-imports quotes from a native CSV file. Quotes can reference existing customers by
/// name or ID; the import service handles customer resolution. Returns JSON with per-row
/// status so the UI can display a success/error breakdown without a page reload.
/// </summary>
// POST: Tools/CsvImportQuotes
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportQuotes(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "Your account is not associated with a company." });
}
if (file == null || file.Length == 0)
{
return Json(new { success = false, message = "No file provided or file is empty." });
}
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
{
return Json(new { success = false, message = "Only CSV files are allowed." });
}
_logger.LogInformation("User {UserName} importing quotes from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportQuotesAsync(stream, companyId.Value);
await LogCsvImportAsync("Quotes", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing quotes from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native job bulk import, with headers matching
/// the columns expected by <see cref="CsvImportJobs"/>.
/// </summary>
// GET: Tools/DownloadJobTemplate
[HttpGet]
public IActionResult DownloadJobTemplate()
{
var csvBytes = _csvImportService.GenerateJobTemplate();
return File(csvBytes, "text/csv", "job_import_template.csv");
}
/// <summary>
/// Bulk-imports jobs from a native CSV file. Job status and priority are lookup-table
/// entities (not enums), so the import service must resolve <c>StatusCode</c> and
/// <c>PriorityCode</c> strings against the company's current lookup tables. Returns JSON
/// with per-row status detail for the UI accordion.
/// </summary>
// POST: Tools/CsvImportJobs
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportJobs(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "Your account is not associated with a company." });
}
if (file == null || file.Length == 0)
{
return Json(new { success = false, message = "No file provided or file is empty." });
}
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
{
return Json(new { success = false, message = "Only CSV files are allowed." });
}
_logger.LogInformation("User {UserName} importing jobs from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportJobsAsync(stream, companyId.Value);
await LogCsvImportAsync("Jobs", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing jobs from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native invoice bulk import, with headers matching
/// the columns expected by <see cref="CsvImportInvoices"/> and produced by
/// <see cref="ExportInvoicesCsv"/> for round-trip compatibility.
/// </summary>
// GET: Tools/DownloadInvoiceTemplate
[HttpGet]
public IActionResult DownloadInvoiceTemplate()
{
var csvBytes = _csvImportService.GenerateInvoiceTemplate();
return File(csvBytes, "text/csv", "invoice_import_template.csv");
}
/// <summary>
/// Bulk-imports invoice headers from a native CSV file. Customers are resolved by
/// CustomerEmail then CustomerName; rows without a match are skipped. Existing invoices
/// matched by InvoiceNumber are updated; new ones are created. Returns JSON with per-row
/// status so the UI can display a success/error breakdown without a page reload.
/// </summary>
// POST: Tools/CsvImportInvoices
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportInvoices(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing invoices from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportInvoicesAsync(stream, companyId.Value);
await LogCsvImportAsync("Invoices", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing invoices from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native payment bulk import.
/// Columns match the native ExportPaymentsCsv output for round-trip compatibility.
/// </summary>
// GET: Tools/DownloadPaymentTemplate
[HttpGet]
public IActionResult DownloadPaymentTemplate()
{
var csvBytes = _csvImportService.GeneratePaymentTemplate();
return File(csvBytes, "text/csv", "payment_import_template.csv");
}
/// <summary>
/// Bulk-imports payment records from a native CSV file. Invoices are resolved by InvoiceNumber.
/// Duplicate payments (same invoice + date + amount) are skipped. Updates the invoice AmountPaid
/// and status after each successful row.
/// </summary>
// POST: Tools/CsvImportPayments
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportPayments(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing payments from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportPaymentsAsync(stream, companyId.Value);
await LogCsvImportAsync("Payments", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing payments from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native purchase order bulk import.
/// Columns match the native ExportPurchaseOrdersCsv output for round-trip compatibility.
/// </summary>
// GET: Tools/DownloadPurchaseOrderTemplate
[HttpGet]
public IActionResult DownloadPurchaseOrderTemplate()
{
var csvBytes = _csvImportService.GeneratePurchaseOrderTemplate();
return File(csvBytes, "text/csv", "purchase_order_import_template.csv");
}
/// <summary>
/// Bulk-imports purchase order headers from a native CSV file. Vendors are resolved by
/// company name. Existing POs matched by PoNumber are updated; new ones are created.
/// </summary>
// POST: Tools/CsvImportPurchaseOrders
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportPurchaseOrders(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing purchase orders from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportPurchaseOrdersAsync(stream, companyId.Value);
await LogCsvImportAsync("PurchaseOrders", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing purchase orders from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native appointment bulk import, with headers
/// matching the columns expected by <see cref="CsvImportAppointments"/>.
/// </summary>
// GET: Tools/DownloadAppointmentTemplate
[HttpGet]
public IActionResult DownloadAppointmentTemplate()
{
var csvBytes = _csvImportService.GenerateAppointmentTemplate();
return File(csvBytes, "text/csv", "appointment_import_template.csv");
}
/// <summary>
/// Bulk-imports customer appointments from a native CSV file. Appointment status and type
/// are lookup-table entities resolved by the import service against the company's current
/// lookup data. Returns JSON with per-row status detail.
/// </summary>
// POST: Tools/CsvImportAppointments
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportAppointments(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "Your account is not associated with a company." });
}
if (file == null || file.Length == 0)
{
return Json(new { success = false, message = "No file provided or file is empty." });
}
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
{
return Json(new { success = false, message = "Only CSV files are allowed." });
}
_logger.LogInformation("User {UserName} importing appointments from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportAppointmentsAsync(stream, companyId.Value);
await LogCsvImportAsync("Appointments", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing appointments from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native equipment bulk import, with headers
/// matching the columns expected by <see cref="CsvImportEquipment"/>.
/// </summary>
// GET: Tools/DownloadEquipmentTemplate
[HttpGet]
public IActionResult DownloadEquipmentTemplate()
{
var csvBytes = _csvImportService.GenerateEquipmentTemplate();
return File(csvBytes, "text/csv", "equipment_import_template.csv");
}
/// <summary>
/// Bulk-imports shop equipment records from a native CSV file. Equipment status is an enum
/// (<c>EquipmentStatus</c>) rather than a lookup-table entity, so the import service parses
/// the string value to the enum directly. Returns JSON with per-row status detail.
/// </summary>
// POST: Tools/CsvImportEquipment
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportEquipment(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "Your account is not associated with a company." });
}
if (file == null || file.Length == 0)
{
return Json(new { success = false, message = "No file provided or file is empty." });
}
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
{
return Json(new { success = false, message = "Only CSV files are allowed." });
}
_logger.LogInformation("User {UserName} importing equipment from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportEquipmentAsync(stream, companyId.Value);
await LogCsvImportAsync("Equipment", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing equipment from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native maintenance-record bulk import, with headers
/// matching the columns expected by <see cref="CsvImportMaintenance"/>.
/// </summary>
// GET: Tools/DownloadMaintenanceTemplate
[HttpGet]
public IActionResult DownloadMaintenanceTemplate()
{
var csvBytes = _csvImportService.GenerateMaintenanceTemplate();
return File(csvBytes, "text/csv", "maintenance_import_template.csv");
}
/// <summary>
/// Bulk-imports equipment maintenance records from a native CSV file. Each record is linked
/// to an equipment item resolved by name or number; unmatched equipment names are reported
/// as row-level errors in the returned JSON. Returns per-row status detail for the UI accordion.
/// </summary>
// POST: Tools/CsvImportMaintenance
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportMaintenance(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "Your account is not associated with a company." });
}
if (file == null || file.Length == 0)
{
return Json(new { success = false, message = "No file provided or file is empty." });
}
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
{
return Json(new { success = false, message = "Only CSV files are allowed." });
}
_logger.LogInformation("User {UserName} importing maintenance records from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportMaintenanceAsync(stream, companyId.Value);
await LogCsvImportAsync("MaintenanceRecords", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing maintenance records from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native vendor bulk import, with headers
/// matching the columns expected by <see cref="CsvImportVendors"/>.
/// </summary>
// GET: Tools/DownloadVendorTemplate
[HttpGet]
public IActionResult DownloadVendorTemplate()
{
var csvBytes = _csvImportService.GenerateVendorTemplate();
return File(csvBytes, "text/csv", "vendor_import_template.csv");
}
/// <summary>
/// Bulk-imports vendor records from a native CSV file, creating or updating vendors for the
/// current company. Returns JSON with per-row status detail for the UI accordion.
/// </summary>
// POST: Tools/CsvImportVendors
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportVendors(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing vendors from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportVendorsAsync(stream, companyId.Value);
await LogCsvImportAsync("Vendors", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing vendors from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native prep-service bulk import, with headers
/// matching the columns expected by <see cref="CsvImportPrepServices"/>.
/// </summary>
// GET: Tools/DownloadPrepServiceTemplate
[HttpGet]
public IActionResult DownloadPrepServiceTemplate()
{
var csvBytes = _csvImportService.GeneratePrepServiceTemplate();
return File(csvBytes, "text/csv", "prep_service_import_template.csv");
}
/// <summary>
/// Bulk-imports prep-service definitions (sandblasting, masking, etc.) from a native CSV file.
/// Prep services are used as line items on quotes and jobs; importing them saves manual entry
/// when onboarding a company with an established service list. Returns JSON with per-row
/// status detail.
/// </summary>
// POST: Tools/CsvImportPrepServices
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportPrepServices(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing prep services from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportPrepServicesAsync(stream, companyId.Value);
await LogCsvImportAsync("PrepServices", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing prep services from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a structured CSV template for company settings import. The template uses a
/// multi-section format: sections marked with square brackets (e.g., "[Company Info]") switch
/// the parser between key-value mode (single settings) and table mode (lookup tables such as
/// Pricing Tiers, Job Statuses). This dual-mode format is why the file is generated inline
/// rather than served as a static asset — the structure documents the exact expected schema.
/// </summary>
// GET: Tools/DownloadCompanySettingsTemplate
[HttpGet]
public IActionResult DownloadCompanySettingsTemplate()
{
try
{
var csvContent = GenerateCompanySettingsTemplate();
var fileName = $"company_settings_template_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
return File(System.Text.Encoding.UTF8.GetBytes(csvContent), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating company settings template");
TempData["ErrorMessage"] = "An error occurred while generating the template.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Imports company settings from a multi-section CSV file (see <see cref="DownloadCompanySettingsTemplate"/>
/// for format). Parsing and upsert logic are handled by <see cref="ImportCompanySettingsFromCsv"/>;
/// this action is responsible only for file validation, stream setup, and converting the result
/// to a JSON response. Returns a list of non-fatal warnings alongside the success flag so users
/// can see which settings were skipped or defaulted without treating the entire import as failed.
/// </summary>
// POST: Tools/CsvImportCompanySettings
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportCompanySettings(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "Your account is not associated with a company." });
}
if (file == null || file.Length == 0)
{
return Json(new { success = false, message = "No file provided or file is empty." });
}
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
{
return Json(new { success = false, message = "Only CSV files are allowed." });
}
_logger.LogInformation("User {UserName} importing company settings from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
using var reader = new System.IO.StreamReader(stream);
var result = await ImportCompanySettingsFromCsv(reader, companyId.Value);
await _auditService.LogAsync("Imported", "CompanySettings", file.FileName,
new { file.FileName, result.Success, Errors = result.Errors.Count });
return Json(new
{
success = result.Success,
message = result.Message,
errors = result.Errors
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing company settings from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
#endregion
#region CSV Export
/// <summary>
/// Exports all exportable entity types (customers, quotes, jobs, appointments, catalog items,
/// inventory, equipment, maintenance records) for the current company as a single ZIP archive.
/// Each entity type becomes a timestamped CSV file inside the archive, making it easy to
/// restore data to a fresh tenant or hand off data to an external system. The ZIP is built
/// entirely in memory using <see cref="System.IO.Compression.ZipArchive"/> so no temporary
/// files are written to disk.
/// </summary>
// GET: Tools/ExportAllCsv
[HttpGet]
public async Task<IActionResult> ExportAllCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["Error"] = "Unable to determine company context.";
return RedirectToAction(nameof(Index));
}
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
using var memoryStream = new System.IO.MemoryStream();
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
{
// 1. Customers
var customers = await _unitOfWork.Customers.GetAllAsync();
var customersCsv = GenerateCustomersCsv(customers);
var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv");
using (var entryStream = customersEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(customersCsv);
}
// 2. Quotes
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus);
var quotesCsv = GenerateQuotesCsv(quotes);
var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv");
using (var entryStream = quotesEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(quotesCsv);
}
// 3. Jobs
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var jobsCsv = GenerateJobsCsv(jobs);
var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv");
using (var entryStream = jobsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(jobsCsv);
}
// 4. Appointments
var appointments = await _unitOfWork.Appointments.GetAllAsync(false,
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
var appointmentsCsv = GenerateAppointmentsCsv(appointments);
var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv");
using (var entryStream = appointmentsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(appointmentsCsv);
}
// 5. Catalog
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
var catalog = await _unitOfWork.CatalogItems.GetAllAsync();
var catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths);
var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv");
using (var entryStream = catalogEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(catalogCsv);
}
// 6. Inventory
var inventory = await _unitOfWork.InventoryItems.GetAllAsync();
var inventoryCsv = GenerateInventoryCsv(inventory);
var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv");
using (var entryStream = inventoryEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(inventoryCsv);
}
// 7. Equipment
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var equipmentCsv = GenerateEquipmentCsv(equipment);
var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv");
using (var entryStream = equipmentEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(equipmentCsv);
}
// 8. Maintenance
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment);
var maintenanceCsv = GenerateMaintenanceCsv(maintenance);
var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv");
using (var entryStream = maintenanceEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(maintenanceCsv);
}
// 9. Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorsCsv = GenerateVendorsCsv(vendors);
var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv");
using (var entryStream = vendorsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(vendorsCsv);
}
// 10. Prep Services
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
var prepServicesCsv = GeneratePrepServicesCsv(prepServices);
var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv");
using (var entryStream = prepServicesEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(prepServicesCsv);
}
// 11. Invoices
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job);
var invoicesCsv = GenerateInvoicesCsv(invoices);
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
using (var entryStream = invoicesEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(invoicesCsv);
}
// 12. Chart of Accounts
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accountsCsv = GenerateChartOfAccountsCsv(accounts);
var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv");
using (var entryStream = accountsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(accountsCsv);
}
// 13. Expenses
var expenses = await _unitOfWork.Expenses.GetAllAsync(false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
var expensesCsv = GenerateExpensesCsv(expenses);
var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv");
using (var entryStream = expensesEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(expensesCsv);
}
// 14. Payments
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
var paymentsCsv = GeneratePaymentsCsv(payments);
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
using (var entryStream = paymentsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(paymentsCsv);
}
// 15. Purchase Orders
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor);
var purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders);
var purchaseOrdersEntry = archive.CreateEntry($"purchase_orders_{timestamp}.csv");
using (var entryStream = purchaseOrdersEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(purchaseOrdersCsv);
}
}
memoryStream.Position = 0;
var zipFileName = $"all_data_export_{timestamp}.zip";
await LogExportAsync("AllData", "Full CSV ZIP export");
return File(memoryStream.ToArray(), "application/zip", zipFileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting all CSV data");
TempData["Error"] = "An error occurred while exporting data.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all customers for the current company as a standalone CSV file using the native
/// (non-QuickBooks) column layout defined in <see cref="GenerateCustomersCsv"/>. Use
/// <see cref="ExportCustomers"/> for QuickBooks IIF/CSV export.
/// </summary>
// GET: Tools/ExportCustomersCsv
[HttpGet]
public async Task<IActionResult> ExportCustomersCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var customers = await _unitOfWork.Customers.GetAllAsync(false, c => c.PricingTier);
var csv = GenerateCustomersCsv(customers);
var fileName = $"customers_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("Customers", $"CSV export ({customers.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting customers to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting customers.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all quotes for the current company as a CSV file. Eagerly loads Customer and
/// QuoteStatus navigation properties so <see cref="GenerateQuotesCsv"/> can output human-readable
/// names without additional round-trips. Prospect quotes (no linked customer) are handled by
/// falling back to ProspectCompanyName or ProspectContactName.
/// </summary>
// GET: Tools/ExportQuotesCsv
[HttpGet]
public async Task<IActionResult> ExportQuotesCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus);
var csv = GenerateQuotesCsv(quotes);
var fileName = $"quotes_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("Quotes", $"CSV export ({quotes.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting quotes to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting quotes.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all jobs for the current company as a CSV file. Eagerly loads Customer,
/// JobStatus, and JobPriority navigation properties because job status and priority are
/// lookup-table entities (not enums) and must be resolved to display names for the export.
/// </summary>
// GET: Tools/ExportJobsCsv
[HttpGet]
public async Task<IActionResult> ExportJobsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var csv = GenerateJobsCsv(jobs);
var fileName = $"jobs_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("Jobs", $"CSV export ({jobs.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting jobs to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting jobs.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all appointments for the current company as a CSV file, including appointment type
/// and status display names resolved from their respective lookup-table navigation properties.
/// </summary>
// GET: Tools/ExportAppointmentsCsv
[HttpGet]
public async Task<IActionResult> ExportAppointmentsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var appointments = await _unitOfWork.Appointments.GetAllAsync(false,
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
var csv = GenerateAppointmentsCsv(appointments);
var fileName = $"appointments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("Appointments", $"CSV export ({appointments.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting appointments to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting appointments.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all catalog items for the current company as a CSV file. Catalog categories form a
/// hierarchy (parent/child), so <see cref="BuildCategoryPathMap"/> is called first to resolve
/// each category's full slash-separated path (e.g., "Metal/Automotive/Wheels") before building
/// the CSV rows, making the export reimportable without needing separate category IDs.
/// </summary>
// GET: Tools/ExportCatalogCsv
[HttpGet]
public async Task<IActionResult> ExportCatalogCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync();
var csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths);
var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("CatalogItems", $"CSV export ({catalogItems.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting catalog to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting catalog.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all inventory items for the current company as a CSV file including current
/// on-hand quantities, unit costs, and reorder points, enabling offline stock analysis or
/// re-import to a fresh environment.
/// </summary>
// GET: Tools/ExportInventoryCsv
[HttpGet]
public async Task<IActionResult> ExportInventoryCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var inventoryItems = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.PrimaryVendor);
var csv = GenerateInventoryCsv(inventoryItems);
var fileName = $"inventory_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("InventoryItems", $"CSV export ({inventoryItems.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting inventory to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting inventory.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all equipment records for the current company as a CSV file. Equipment status is
/// written as its enum name (e.g., "Operational") so the CSV is human-readable and can be
/// re-imported by the native CSV import which parses it back to the enum.
/// </summary>
// GET: Tools/ExportEquipmentCsv
[HttpGet]
public async Task<IActionResult> ExportEquipmentCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var csv = GenerateEquipmentCsv(equipment);
var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("Equipment", $"CSV export ({equipment.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting equipment to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting equipment.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all maintenance records for the current company as a CSV file. Note that the
/// repository call does not eagerly load the <c>Equipment</c> navigation property, so
/// <see cref="GenerateMaintenanceCsv"/> relies on EF lazy loading (or an already-tracked entity)
/// to resolve equipment names. If lazy loading is disabled this column will be empty.
/// </summary>
// GET: Tools/ExportMaintenanceCsv
[HttpGet]
public async Task<IActionResult> ExportMaintenanceCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment);
var csv = GenerateMaintenanceCsv(maintenance);
var fileName = $"maintenance_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("MaintenanceRecords", $"CSV export ({maintenance.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting maintenance to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting maintenance records.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports the full company configuration — operating costs, preferences, pricing tiers, and
/// all status/priority/category lookup tables — as a structured CSV file that can be re-imported
/// via <see cref="CsvImportCompanySettings"/>. Lookup tables (job statuses, job priorities, etc.)
/// are loaded separately from the company entity because they are not navigation properties on
/// <c>Company</c>; the multi-tenancy global query filter scopes them to the current company
/// automatically.
/// </summary>
// GET: Tools/ExportCompanySettingsCsv
[HttpGet]
public async Task<IActionResult> ExportCompanySettingsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false,
c => c.OperatingCosts,
c => c.Preferences,
c => c.PricingTiers);
if (company == null)
{
TempData["ErrorMessage"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
// Load all lookup tables
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync();
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities,
quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes);
var fileName = $"company_settings_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("CompanySettings", "CSV export");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting company settings to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting company settings.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all invoices for the current company as a CSV file, including customer name and
/// linked job number resolved via eager loading. Invoice status is written as its enum name
/// (e.g., "PartiallyPaid") so the data can be consumed by external accounting or reporting tools.
/// </summary>
// GET: Tools/ExportInvoicesCsv
[HttpGet]
public async Task<IActionResult> ExportInvoicesCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job);
var csv = GenerateInvoicesCsv(invoices);
var fileName = $"invoices_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("Invoices", $"CSV export ({invoices.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting invoices to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting invoices.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all invoice payment records for the current company as a CSV file, including the
/// parent invoice number resolved via eager loading. Payment method is written as its enum name.
/// This export covers only customer payments (AR); vendor bill payments are not included here.
/// </summary>
// GET: Tools/ExportPaymentsCsv
[HttpGet]
public async Task<IActionResult> ExportPaymentsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
var csv = GeneratePaymentsCsv(payments);
var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("Payments", $"CSV export ({payments.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting payments to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting payments.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all purchase orders for the current company as a CSV file, including the vendor
/// company name resolved via eager loading. PO status is written as its enum name.
/// </summary>
// GET: Tools/ExportPurchaseOrdersCsv
[HttpGet]
public async Task<IActionResult> ExportPurchaseOrdersCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor);
var csv = GeneratePurchaseOrdersCsv(purchaseOrders);
var fileName = $"purchase_orders_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("PurchaseOrders", $"CSV export ({purchaseOrders.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting purchase orders to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting purchase orders.";
return RedirectToAction(nameof(Index));
}
}
#endregion
#region CSV Generation Helpers
/// <summary>
/// Builds a CSV string for the given customer collection using the native column layout that
/// matches <see cref="ICsvImportService.ImportCustomersAsync"/>. Each field is passed through
/// <see cref="EscapeCsv"/> to handle commas, quotes, and newlines embedded in data values.
/// </summary>
private string GenerateCustomersCsv(IEnumerable<Core.Entities.Customer> customers)
{
var sb = new System.Text.StringBuilder();
// Column names match CustomerImportDto [Name] attributes exactly for round-trip compatibility.
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes");
foreach (var customer in customers)
{
var customerType = customer.IsCommercial ? "Commercial" : "NonCommercial";
sb.AppendLine($"{EscapeCsv(customer.CompanyName)},{EscapeCsv(customer.ContactFirstName)},{EscapeCsv(customer.ContactLastName)},{EscapeCsv(customer.Email)},{EscapeCsv(customer.Phone)},{EscapeCsv(customer.MobilePhone)},{EscapeCsv(customer.Address)},{EscapeCsv(customer.City)},{EscapeCsv(customer.State)},{EscapeCsv(customer.ZipCode)},{EscapeCsv(customer.Country)},{customerType},{EscapeCsv(customer.PricingTier?.TierName)},{customer.CreditLimit},{EscapeCsv(customer.PaymentTerms)},{customer.IsTaxExempt.ToString().ToLower()},{EscapeCsv(customer.TaxId)},{customer.IsActive.ToString().ToLower()},{EscapeCsv(customer.GeneralNotes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV string for the given quote collection. Customer name resolution falls back to
/// ProspectCompanyName then ProspectContactName then "Unknown" to handle quotes that were
/// created for prospects and never converted to a linked customer record.
/// <c>QuoteStatus.StatusCode</c> is exported (not DisplayName) so the importer can resolve it
/// by the same code the import dict is keyed on.
/// </summary>
private string GenerateQuotesCsv(IEnumerable<Core.Entities.Quote> quotes)
{
var sb = new System.Text.StringBuilder();
// Column names match QuoteImportDto [Name] attributes for round-trip compatibility.
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
foreach (var quote in quotes)
{
var customerName = !string.IsNullOrWhiteSpace(quote.Customer?.CompanyName)
? quote.Customer.CompanyName
: $"{quote.Customer?.ContactFirstName} {quote.Customer?.ContactLastName}".Trim();
sb.AppendLine($"{EscapeCsv(quote.QuoteNumber)},{EscapeCsv(quote.Customer?.Email)},{EscapeCsv(customerName)},{EscapeCsv(quote.ProspectCompanyName)},{EscapeCsv(quote.ProspectContactName)},{EscapeCsv(quote.ProspectEmail)},{EscapeCsv(quote.ProspectPhone)},{EscapeCsv(quote.QuoteStatus?.StatusCode)},{quote.QuoteDate:yyyy-MM-dd},{quote.ExpirationDate?.ToString("yyyy-MM-dd")},{quote.SubTotal},{quote.TaxAmount},{quote.Total},{EscapeCsv(quote.Notes)},{EscapeCsv(quote.Terms)}");
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV string for the given job collection. JobStatus and JobPriority are lookup-table
/// navigation properties (not enums) — their <c>StatusCode</c>/<c>PriorityCode</c> values are
/// exported so the importer can resolve them by code (which is what the import dict is keyed on).
/// Callers must have eagerly loaded both navigation properties before calling this helper.
/// </summary>
private string GenerateJobsCsv(IEnumerable<Core.Entities.Job> jobs)
{
var sb = new System.Text.StringBuilder();
// Import-compatible columns first (match JobImportDto [Name] attributes), then read-only extras.
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes,CompletedDate,QuotedPrice,ActualTimeHours");
foreach (var job in jobs)
{
var customerName = !string.IsNullOrWhiteSpace(job.Customer?.CompanyName)
? job.Customer.CompanyName
: $"{job.Customer?.ContactFirstName} {job.Customer?.ContactLastName}".Trim();
sb.AppendLine($"{EscapeCsv(job.JobNumber)},{EscapeCsv(job.Customer?.Email)},{EscapeCsv(customerName)},{EscapeCsv(job.JobStatus?.StatusCode)},{EscapeCsv(job.JobPriority?.PriorityCode)},{job.ScheduledDate?.ToString("yyyy-MM-dd")},{job.DueDate?.ToString("yyyy-MM-dd")},{job.FinalPrice},{EscapeCsv(job.CustomerPO)},{EscapeCsv(job.SpecialInstructions)},{EscapeCsv(job.InternalNotes)},{job.CompletedDate?.ToString("yyyy-MM-dd")},{job.QuotedPrice},{job.ActualTimeSpentHours}");
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV string for the given appointment collection. Appointment type and status are
/// lookup-table navigation properties whose <c>DisplayName</c> is used in the export.
/// Scheduled times are exported in ISO-8601 combined date-time format (yyyy-MM-dd HH:mm) to
/// avoid ambiguity in locale-specific date parsing during re-import.
/// </summary>
private string GenerateAppointmentsCsv(IEnumerable<Core.Entities.Appointment> appointments)
{
var sb = new System.Text.StringBuilder();
// Column names match AppointmentImportDto [Name] attributes for round-trip compatibility.
sb.AppendLine("AppointmentNumber,CustomerEmail,AppointmentType,Status,ScheduledStart,ScheduledEnd,Title,Description,Location,Notes");
foreach (var appointment in appointments)
{
sb.AppendLine($"{EscapeCsv(appointment.AppointmentNumber)},{EscapeCsv(appointment.Customer?.Email)},{EscapeCsv(appointment.AppointmentType?.TypeCode)},{EscapeCsv(appointment.AppointmentStatus?.StatusCode)},{appointment.ScheduledStartTime:yyyy-MM-dd HH:mm},{appointment.ScheduledEndTime:yyyy-MM-dd HH:mm},{EscapeCsv(appointment.Title)},{EscapeCsv(appointment.Description)},{EscapeCsv(appointment.Location)},{EscapeCsv(appointment.Notes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV string for the given catalog items, using the pre-computed
/// <paramref name="categoryPaths"/> dictionary to write the full hierarchical category path
/// (e.g., "Metal/Automotive") rather than an opaque category ID. This makes the export
/// directly re-importable without the user needing to re-create the category hierarchy first.
/// </summary>
private string GenerateCatalogCsv(IEnumerable<Core.Entities.CatalogItem> catalogItems, Dictionary<int, string> categoryPaths)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("CategoryPath,ItemName,SKU,Description,BasePrice,ApproximateArea,EstimatedMinutes,RequiresSandblasting,RequiresMasking,IsActive");
foreach (var item in catalogItems)
{
var categoryPath = categoryPaths.TryGetValue(item.CategoryId, out var path) ? path : string.Empty;
sb.AppendLine($"{EscapeCsv(categoryPath)},{EscapeCsv(item.Name)},{EscapeCsv(item.SKU)}," +
$"{EscapeCsv(item.Description)},{item.DefaultPrice},{item.ApproximateArea}," +
$"{item.DefaultEstimatedMinutes},{item.DefaultRequiresSandblasting}," +
$"{item.DefaultRequiresMasking},{item.IsActive}");
}
return sb.ToString();
}
/// <summary>
/// Converts the flat list of catalog categories into a dictionary mapping each category ID to
/// its full slash-separated ancestor path (e.g., "Metal/Automotive/Wheels"). The algorithm
/// walks up the <c>ParentCategoryId</c> chain for each category, prepending ancestor names,
/// until it reaches a root category (null parent). This is O(n*d) where d is the tree depth,
/// which is acceptable for the small category trees expected in this domain. The result is
/// passed into <see cref="GenerateCatalogCsv"/> to avoid recomputing paths per row.
/// </summary>
private static Dictionary<int, string> BuildCategoryPathMap(IEnumerable<Core.Entities.CatalogCategory> categories)
{
var all = categories.ToDictionary(c => c.Id);
var result = new Dictionary<int, string>();
foreach (var category in all.Values)
{
var parts = new System.Collections.Generic.List<string>();
var current = category;
while (current != null)
{
parts.Insert(0, current.Name);
current = current.ParentCategoryId.HasValue && all.TryGetValue(current.ParentCategoryId.Value, out var parent)
? parent
: null;
}
result[category.Id] = string.Join("/", parts);
}
return result;
}
/// <summary>
/// Builds a CSV string for the given inventory items using the native column layout that
/// matches the inventory import template. Numeric fields (UnitCost, QuantityOnHand, ReorderPoint)
/// are written without currency symbols so they can be parsed as decimals on re-import.
/// </summary>
private string GenerateInventoryCsv(IEnumerable<Core.Entities.InventoryItem> inventoryItems)
{
var sb = new System.Text.StringBuilder();
// Column names match InventoryItemImportDto [Name] attributes for round-trip compatibility.
sb.AppendLine("SKU,ItemName,Description,CategoryName,Manufacturer,ManufacturerPartNumber,ColorName,ColorCode,Finish,VendorName,VendorPartNumber,QuantityInStock,UnitOfMeasure,UnitCost,LastPurchasePrice,ReorderPoint,ReorderQuantity,MinimumStock,MaximumStock,CoverageSqFtPerLb,TransferEfficiencyPct,Location,IsActive,Notes");
foreach (var item in inventoryItems)
{
sb.AppendLine($"{EscapeCsv(item.SKU)},{EscapeCsv(item.Name)},{EscapeCsv(item.Description)},{EscapeCsv(item.Category)},{EscapeCsv(item.Manufacturer)},{EscapeCsv(item.ManufacturerPartNumber)},{EscapeCsv(item.ColorName)},{EscapeCsv(item.ColorCode)},{EscapeCsv(item.Finish)},{EscapeCsv(item.PrimaryVendor?.CompanyName)},{EscapeCsv(item.VendorPartNumber)},{item.QuantityOnHand},{EscapeCsv(item.UnitOfMeasure)},{item.UnitCost},{item.LastPurchasePrice},{item.ReorderPoint},{item.ReorderQuantity},{item.MinimumStock},{item.MaximumStock},{item.CoverageSqFtPerLb},{item.TransferEfficiency},{EscapeCsv(item.Location)},{item.IsActive.ToString().ToLower()},{EscapeCsv(item.Notes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV string for the given equipment records. <c>Status</c> is written as its enum
/// name (e.g., "Operational", "UnderMaintenance") without special handling because the import
/// service parses it back using <c>Enum.Parse</c> case-insensitively.
/// </summary>
private string GenerateEquipmentCsv(IEnumerable<Core.Entities.Equipment> equipment)
{
var sb = new System.Text.StringBuilder();
// Column names match EquipmentImportDto [Name] attributes for round-trip compatibility.
sb.AppendLine("EquipmentName,EquipmentNumber,EquipmentType,Manufacturer,Model,SerialNumber,PurchaseDate,PurchasePrice,WarrantyExpiration,Location,RecommendedMaintenanceIntervalDays,Status,IsActive,Notes");
foreach (var item in equipment)
{
sb.AppendLine($"{EscapeCsv(item.EquipmentName)},{EscapeCsv(item.EquipmentNumber)},{EscapeCsv(item.EquipmentType)},{EscapeCsv(item.Manufacturer)},{EscapeCsv(item.Model)},{EscapeCsv(item.SerialNumber)},{item.PurchaseDate?.ToString("yyyy-MM-dd")},{item.PurchasePrice},{item.WarrantyExpiration?.ToString("yyyy-MM-dd")},{EscapeCsv(item.Location)},{item.RecommendedMaintenanceIntervalDays},{item.Status},{item.IsActive.ToString().ToLower()},{EscapeCsv(item.Notes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV string for the given maintenance records. The <c>Equipment</c> navigation
/// property is accessed directly on each record; callers must ensure it is loaded (either via
/// eager loading or lazy loading) before invoking this helper, or the Equipment column will
/// be null/empty in the output.
/// </summary>
private string GenerateMaintenanceCsv(IEnumerable<Core.Entities.MaintenanceRecord> maintenance)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("EquipmentName,MaintenanceType,ScheduledDate,CompletedDate,Status,Priority,LaborCost,PartsCost,TotalCost,Description");
foreach (var record in maintenance)
{
sb.AppendLine($"{EscapeCsv(record.Equipment?.EquipmentName)},{record.MaintenanceType}," +
$"{record.ScheduledDate:yyyy-MM-dd},{record.CompletedDate?.ToString("yyyy-MM-dd")}," +
$"{record.Status},{record.Priority},{record.LaborCost},{record.PartsCost},{record.TotalCost},{EscapeCsv(record.Description)}");
}
return sb.ToString();
}
/// <summary>
/// Builds a re-importable CSV for all prep services. Columns match PrepServiceImportDto
/// so the file can be fed directly back into <see cref="CsvImportPrepServices"/>.
/// </summary>
private string GeneratePrepServicesCsv(IEnumerable<Core.Entities.PrepService> prepServices)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("ServiceName,Description,DisplayOrder,IsActive");
foreach (var ps in prepServices.OrderBy(p => p.DisplayOrder))
{
sb.AppendLine($"{EscapeCsv(ps.ServiceName)},{EscapeCsv(ps.Description)},{ps.DisplayOrder},{ps.IsActive.ToString().ToLower()}");
}
return sb.ToString();
}
/// <summary>
/// Generates a human-readable, instructional CSV template that demonstrates the multi-section
/// format expected by <see cref="ImportCompanySettingsFromCsv"/>. Comment lines (prefixed with
/// "#") explain the rules; section headers in square brackets switch the parser between
/// key-value mode and table mode. Sample data rows are included so users understand the
/// expected value format for each field without consulting separate documentation.
/// </summary>
private string GenerateCompanySettingsTemplate()
{
var sb = new System.Text.StringBuilder();
// Header
sb.AppendLine("# Company Settings Import Template");
sb.AppendLine("# Instructions:");
sb.AppendLine("# - For single-value sections (Company Info, Operating Costs, Preferences), use Key,Value format");
sb.AppendLine("# - For table sections (Pricing Tiers, Lookups), use CSV table format with headers");
sb.AppendLine("# - Leave single-value fields blank to keep existing values unchanged");
sb.AppendLine("# - Table sections REPLACE all existing records - include all records you want to keep!");
sb.AppendLine("# - All monetary values: numbers only, no currency symbols");
sb.AppendLine("# - All percentages: decimal numbers (e.g., 15 for 15%)");
sb.AppendLine("# - Boolean values: true or false (lowercase)");
sb.AppendLine();
// Company Info
sb.AppendLine("[Company Info]");
sb.AppendLine("CompanyName,");
sb.AppendLine("CompanyCode,");
sb.AppendLine("PrimaryContactName,");
sb.AppendLine("PrimaryContactEmail,");
sb.AppendLine("Phone,");
sb.AppendLine("Address,");
sb.AppendLine("City,");
sb.AppendLine("State,");
sb.AppendLine("ZipCode,");
sb.AppendLine("TimeZone,America/New_York");
sb.AppendLine();
// Operating Costs
sb.AppendLine("[Operating Costs]");
sb.AppendLine("StandardLaborRate,65.00");
sb.AppendLine("AdditionalCoatLaborPercent,30");
sb.AppendLine("OvenOperatingCostPerHour,25.00");
sb.AppendLine("SandblasterCostPerHour,35.00");
sb.AppendLine("CoatingBoothCostPerHour,30.00");
sb.AppendLine("PowderCoatingCostPerSqFt,0.50");
sb.AppendLine("GeneralMarkupPercentage,35");
sb.AppendLine("TaxPercent,8.5");
sb.AppendLine("ShopSuppliesRate,5");
sb.AppendLine("RushChargeType,Percentage");
sb.AppendLine("RushChargePercentage,25");
sb.AppendLine("RushChargeFixedAmount,0");
sb.AppendLine("ShopMinimumCharge,50.00");
sb.AppendLine();
// Preferences
sb.AppendLine("[Preferences]");
sb.AppendLine("DefaultCurrency,USD");
sb.AppendLine("DefaultDateFormat,MM/dd/yyyy");
sb.AppendLine("DefaultTimeFormat,12h");
sb.AppendLine("DefaultPaymentTerms,Net 30");
sb.AppendLine("DefaultQuoteValidityDays,30");
sb.AppendLine("QuoteNumberPrefix,QT");
sb.AppendLine("JobNumberPrefix,JOB");
sb.AppendLine("UseMetricSystem,false");
sb.AppendLine("DefaultJobPriority,Normal");
sb.AppendLine("RequireCustomerPO,false");
sb.AppendLine("AllowCustomerApproval,true");
sb.AppendLine("DefaultTurnaroundDays,7");
sb.AppendLine("EmailNotificationsEnabled,true");
sb.AppendLine("NotifyOnNewJob,true");
sb.AppendLine("NotifyOnJobStatusChange,true");
sb.AppendLine("NotifyOnQuoteApproval,true");
sb.AppendLine("NotifyOnPaymentReceived,true");
sb.AppendLine("QuoteExpiryWarningDays,3");
sb.AppendLine("DueDateWarningDays,2");
sb.AppendLine("MaintenanceAlertDays,7");
sb.AppendLine("QuoteRetentionYears,7");
sb.AppendLine("JobRetentionYears,7");
sb.AppendLine("LogRetentionDays,90");
sb.AppendLine("AutoArchiveJobsDays,365");
sb.AppendLine("DeletedRecordRetentionDays,30");
sb.AppendLine();
// Pricing Tiers
sb.AppendLine("[Pricing Tiers]");
sb.AppendLine("TierName,Description,DiscountPercent,IsActive");
sb.AppendLine("Standard,Standard pricing tier,0,true");
sb.AppendLine("Silver,5% discount for regular customers,5,true");
sb.AppendLine("Gold,10% discount for preferred customers,10,true");
sb.AppendLine("Platinum,15% discount for VIP customers,15,true");
sb.AppendLine();
// Job Status Lookups
sb.AppendLine("[Job Statuses]");
sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,IsSystemDefined,IsTerminalStatus,IsWorkInProgressStatus,WorkflowCategory,Description");
sb.AppendLine("PENDING,Pending,1,secondary,bi-clock,true,true,false,false,Pre-Production,Job created but not yet started");
sb.AppendLine("INPREPARATION,In Preparation,2,info,bi-wrench,true,false,false,true,Pre-Production,Preparing parts for coating");
sb.AppendLine("COATING,Coating,3,primary,bi-paint-bucket,true,false,false,true,Production,Applying powder coating");
sb.AppendLine("COMPLETED,Completed,4,success,bi-check-circle,true,true,true,false,Post-Production,Job finished");
sb.AppendLine();
// Job Priority Lookups
sb.AppendLine("[Job Priorities]");
sb.AppendLine("PriorityCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive");
sb.AppendLine("LOW,Low,1,secondary,bi-arrow-down,true");
sb.AppendLine("NORMAL,Normal,2,primary,bi-dash,true");
sb.AppendLine("HIGH,High,3,warning,bi-arrow-up,true");
sb.AppendLine("URGENT,Urgent,4,danger,bi-exclamation-triangle,true");
sb.AppendLine();
// Quote Status Lookups
sb.AppendLine("[Quote Statuses]");
sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,IsApprovedStatus,IsConvertedStatus,IsDraftStatus");
sb.AppendLine("DRAFT,Draft,1,secondary,bi-pencil,true,false,false,true");
sb.AppendLine("SENT,Sent,2,info,bi-send,true,false,false,false");
sb.AppendLine("APPROVED,Approved,3,success,bi-check-circle,true,true,false,false");
sb.AppendLine("REJECTED,Rejected,4,danger,bi-x-circle,true,false,false,false");
sb.AppendLine();
// Inventory Category Lookups
sb.AppendLine("[Inventory Categories]");
sb.AppendLine("CategoryCode,DisplayName,DisplayOrder,IsActive,IsSystemDefined,IsCoating,Description");
sb.AppendLine("POWDER,Powder Coating,1,true,true,true,Powder coating materials");
sb.AppendLine("CONSUMABLES,Consumables,2,true,false,false,Shop consumables and supplies");
sb.AppendLine();
// Appointment Status Lookups
sb.AppendLine("[Appointment Statuses]");
sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive");
sb.AppendLine("SCHEDULED,Scheduled,1,primary,bi-calendar-check,true");
sb.AppendLine("CONFIRMED,Confirmed,2,success,bi-check2-circle,true");
sb.AppendLine("COMPLETED,Completed,3,success,bi-check-circle,true");
sb.AppendLine("CANCELLED,Cancelled,4,danger,bi-x-circle,true");
sb.AppendLine();
// Appointment Type Lookups
sb.AppendLine("[Appointment Types]");
sb.AppendLine("TypeCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,Description");
sb.AppendLine("ESTIMATE,Estimate,1,info,bi-calculator,true,On-site estimate appointment");
sb.AppendLine("DROPOFF,Drop-off,2,primary,bi-box-arrow-in-down,true,Customer dropping off parts");
sb.AppendLine("PICKUP,Pick-up,3,success,bi-box-arrow-up,true,Customer picking up completed job");
sb.AppendLine();
return sb.ToString();
}
/// <summary>
/// Serialises the current company's live settings into the multi-section CSV format, producing
/// a file that can be round-tripped back through <see cref="ImportCompanySettingsFromCsv"/>.
/// Null OperatingCosts or Preferences sections are silently omitted so a partially configured
/// company still produces a valid (if incomplete) export. Pricing tiers filter out soft-deleted
/// records (<c>!t.IsDeleted</c>) because the export is intended to capture the active configuration.
/// </summary>
private string GenerateCompanySettingsCsv( Core.Entities.Company company,
IEnumerable<Core.Entities.JobStatusLookup> jobStatuses,
IEnumerable<Core.Entities.JobPriorityLookup> jobPriorities,
IEnumerable<Core.Entities.QuoteStatusLookup> quoteStatuses,
IEnumerable<Core.Entities.InventoryCategoryLookup> inventoryCategories,
IEnumerable<Core.Entities.AppointmentStatusLookup> appointmentStatuses,
IEnumerable<Core.Entities.AppointmentTypeLookup> appointmentTypes)
{
var sb = new System.Text.StringBuilder();
// Company Info
sb.AppendLine("[Company Info]");
sb.AppendLine($"CompanyName,{EscapeCsv(company.CompanyName)}");
sb.AppendLine($"CompanyCode,{EscapeCsv(company.CompanyCode)}");
sb.AppendLine($"PrimaryContactName,{EscapeCsv(company.PrimaryContactName)}");
sb.AppendLine($"PrimaryContactEmail,{EscapeCsv(company.PrimaryContactEmail)}");
sb.AppendLine($"Phone,{EscapeCsv(company.Phone)}");
sb.AppendLine($"Address,{EscapeCsv(company.Address)}");
sb.AppendLine($"City,{EscapeCsv(company.City)}");
sb.AppendLine($"State,{EscapeCsv(company.State)}");
sb.AppendLine($"ZipCode,{EscapeCsv(company.ZipCode)}");
sb.AppendLine($"TimeZone,{EscapeCsv(company.TimeZone)}");
sb.AppendLine();
// Operating Costs
if (company.OperatingCosts != null)
{
var costs = company.OperatingCosts;
sb.AppendLine("[Operating Costs]");
sb.AppendLine($"StandardLaborRate,{costs.StandardLaborRate}");
sb.AppendLine($"AdditionalCoatLaborPercent,{costs.AdditionalCoatLaborPercent}");
sb.AppendLine($"OvenOperatingCostPerHour,{costs.OvenOperatingCostPerHour}");
sb.AppendLine($"SandblasterCostPerHour,{costs.SandblasterCostPerHour}");
sb.AppendLine($"CoatingBoothCostPerHour,{costs.CoatingBoothCostPerHour}");
sb.AppendLine($"PowderCoatingCostPerSqFt,{costs.PowderCoatingCostPerSqFt}");
sb.AppendLine($"GeneralMarkupPercentage,{costs.GeneralMarkupPercentage}");
sb.AppendLine($"TaxPercent,{costs.TaxPercent}");
sb.AppendLine($"ShopSuppliesRate,{costs.ShopSuppliesRate}");
sb.AppendLine($"RushChargeType,{EscapeCsv(costs.RushChargeType)}");
sb.AppendLine($"RushChargePercentage,{costs.RushChargePercentage}");
sb.AppendLine($"RushChargeFixedAmount,{costs.RushChargeFixedAmount}");
sb.AppendLine($"ShopMinimumCharge,{costs.ShopMinimumCharge}");
sb.AppendLine();
}
// Preferences
if (company.Preferences != null)
{
var prefs = company.Preferences;
sb.AppendLine("[Preferences]");
sb.AppendLine($"DefaultCurrency,{EscapeCsv(prefs.DefaultCurrency)}");
sb.AppendLine($"DefaultDateFormat,{EscapeCsv(prefs.DefaultDateFormat)}");
sb.AppendLine($"DefaultTimeFormat,{EscapeCsv(prefs.DefaultTimeFormat)}");
sb.AppendLine($"DefaultPaymentTerms,{EscapeCsv(prefs.DefaultPaymentTerms)}");
sb.AppendLine($"DefaultQuoteValidityDays,{prefs.DefaultQuoteValidityDays}");
sb.AppendLine($"QuoteNumberPrefix,{EscapeCsv(prefs.QuoteNumberPrefix)}");
sb.AppendLine($"JobNumberPrefix,{EscapeCsv(prefs.JobNumberPrefix)}");
sb.AppendLine($"UseMetricSystem,{prefs.UseMetricSystem.ToString().ToLower()}");
sb.AppendLine($"DefaultJobPriority,{EscapeCsv(prefs.DefaultJobPriority)}");
sb.AppendLine($"RequireCustomerPO,{prefs.RequireCustomerPO.ToString().ToLower()}");
sb.AppendLine($"AllowCustomerApproval,{prefs.AllowCustomerApproval.ToString().ToLower()}");
sb.AppendLine($"DefaultTurnaroundDays,{prefs.DefaultTurnaroundDays}");
sb.AppendLine($"EmailNotificationsEnabled,{prefs.EmailNotificationsEnabled.ToString().ToLower()}");
sb.AppendLine($"NotifyOnNewJob,{prefs.NotifyOnNewJob.ToString().ToLower()}");
sb.AppendLine($"NotifyOnJobStatusChange,{prefs.NotifyOnJobStatusChange.ToString().ToLower()}");
sb.AppendLine($"NotifyOnQuoteApproval,{prefs.NotifyOnQuoteApproval.ToString().ToLower()}");
sb.AppendLine($"NotifyOnPaymentReceived,{prefs.NotifyOnPaymentReceived.ToString().ToLower()}");
sb.AppendLine($"QuoteExpiryWarningDays,{prefs.QuoteExpiryWarningDays}");
sb.AppendLine($"DueDateWarningDays,{prefs.DueDateWarningDays}");
sb.AppendLine($"MaintenanceAlertDays,{prefs.MaintenanceAlertDays}");
sb.AppendLine($"QuoteRetentionYears,{prefs.QuoteRetentionYears}");
sb.AppendLine($"JobRetentionYears,{prefs.JobRetentionYears}");
sb.AppendLine($"LogRetentionDays,{prefs.LogRetentionDays}");
sb.AppendLine($"AutoArchiveJobsDays,{prefs.AutoArchiveJobsDays}");
sb.AppendLine($"DeletedRecordRetentionDays,{prefs.DeletedRecordRetentionDays}");
sb.AppendLine();
}
// Pricing Tiers
sb.AppendLine("[Pricing Tiers]");
sb.AppendLine("TierName,Description,DiscountPercent,IsActive");
foreach (var tier in company.PricingTiers.Where(t => !t.IsDeleted).OrderBy(t => t.TierName))
{
sb.AppendLine($"{EscapeCsv(tier.TierName)},{EscapeCsv(tier.Description)},{tier.DiscountPercent},{tier.IsActive.ToString().ToLower()}");
}
sb.AppendLine();
// Job Status Lookups
sb.AppendLine("[Job Statuses]");
sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,IsSystemDefined,IsTerminalStatus,IsWorkInProgressStatus,WorkflowCategory,Description");
foreach (var status in jobStatuses.OrderBy(s => s.DisplayOrder))
{
sb.AppendLine($"{EscapeCsv(status.StatusCode)},{EscapeCsv(status.DisplayName)},{status.DisplayOrder}," +
$"{EscapeCsv(status.ColorClass)},{EscapeCsv(status.IconClass)},{status.IsActive.ToString().ToLower()}," +
$"{status.IsSystemDefined.ToString().ToLower()},{status.IsTerminalStatus.ToString().ToLower()}," +
$"{status.IsWorkInProgressStatus.ToString().ToLower()},{EscapeCsv(status.WorkflowCategory)},{EscapeCsv(status.Description)}");
}
sb.AppendLine();
// Job Priority Lookups
sb.AppendLine("[Job Priorities]");
sb.AppendLine("PriorityCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive");
foreach (var priority in jobPriorities.OrderBy(p => p.DisplayOrder))
{
sb.AppendLine($"{EscapeCsv(priority.PriorityCode)},{EscapeCsv(priority.DisplayName)},{priority.DisplayOrder}," +
$"{EscapeCsv(priority.ColorClass)},{EscapeCsv(priority.IconClass)},{priority.IsActive.ToString().ToLower()}");
}
sb.AppendLine();
// Quote Status Lookups
sb.AppendLine("[Quote Statuses]");
sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,IsApprovedStatus,IsConvertedStatus,IsDraftStatus");
foreach (var status in quoteStatuses.OrderBy(s => s.DisplayOrder))
{
sb.AppendLine($"{EscapeCsv(status.StatusCode)},{EscapeCsv(status.DisplayName)},{status.DisplayOrder}," +
$"{EscapeCsv(status.ColorClass)},{EscapeCsv(status.IconClass)},{status.IsActive.ToString().ToLower()}," +
$"{status.IsApprovedStatus.ToString().ToLower()},{status.IsConvertedStatus.ToString().ToLower()}," +
$"{status.IsDraftStatus.ToString().ToLower()}");
}
sb.AppendLine();
// Inventory Category Lookups
sb.AppendLine("[Inventory Categories]");
sb.AppendLine("CategoryCode,DisplayName,DisplayOrder,IsActive,IsSystemDefined,IsCoating,Description");
foreach (var category in inventoryCategories.OrderBy(c => c.DisplayOrder))
{
sb.AppendLine($"{EscapeCsv(category.CategoryCode)},{EscapeCsv(category.DisplayName)},{category.DisplayOrder}," +
$"{category.IsActive.ToString().ToLower()},{category.IsSystemDefined.ToString().ToLower()}," +
$"{category.IsCoating.ToString().ToLower()},{EscapeCsv(category.Description)}");
}
sb.AppendLine();
// Appointment Status Lookups
sb.AppendLine("[Appointment Statuses]");
sb.AppendLine("StatusCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive");
foreach (var status in appointmentStatuses.OrderBy(s => s.DisplayOrder))
{
sb.AppendLine($"{EscapeCsv(status.StatusCode)},{EscapeCsv(status.DisplayName)},{status.DisplayOrder}," +
$"{EscapeCsv(status.ColorClass)},{EscapeCsv(status.IconClass)},{status.IsActive.ToString().ToLower()}");
}
sb.AppendLine();
// Appointment Type Lookups
sb.AppendLine("[Appointment Types]");
sb.AppendLine("TypeCode,DisplayName,DisplayOrder,ColorClass,IconClass,IsActive,Description");
foreach (var type in appointmentTypes.OrderBy(t => t.DisplayOrder))
{
sb.AppendLine($"{EscapeCsv(type.TypeCode)},{EscapeCsv(type.DisplayName)},{type.DisplayOrder}," +
$"{EscapeCsv(type.ColorClass)},{EscapeCsv(type.IconClass)},{type.IsActive.ToString().ToLower()},{EscapeCsv(type.Description)}");
}
return sb.ToString();
}
/// <summary>
/// Core parser and upsert engine for company-settings CSV import. Reads the file line by line,
/// switching between key-value mode (for Company Info, Operating Costs, Preferences) and table
/// mode (for Pricing Tiers and all lookup tables) based on section headers. Key-value fields
/// that parse successfully are applied to the in-memory entity; parse errors are collected as
/// non-fatal warnings (the row is skipped, but processing continues). Table sections are
/// accumulated in memory, then processed after the full file is read, so the final table state
/// reflects the entire section rather than partial rows applied mid-stream. A single
/// <c>CompleteAsync</c> call at the end commits all changes atomically.
/// <para>
/// Design caveat: Empty CSV values are silently skipped for key-value rows (so a blank value
/// means "keep existing"), but table sections REPLACE all existing records — the caller must
/// include every row they want to retain in a table section export.
/// </para>
/// </summary>
private async Task<(bool Success, string Message, List<string> Errors)> ImportCompanySettingsFromCsv(System.IO.StreamReader reader, int companyId)
{
var errors = new List<string>();
var currentSection = "";
var lineNumber = 0;
var tableSections = new Dictionary<string, List<string[]>>();
var tableHeaders = new Dictionary<string, string[]>();
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, false,
c => c.OperatingCosts,
c => c.Preferences,
c => c.PricingTiers);
if (company == null)
{
return (false, "Company not found.", errors);
}
// Create operating costs if null
if (company.OperatingCosts == null)
{
company.OperatingCosts = new Core.Entities.CompanyOperatingCosts
{
CompanyId = companyId
};
}
// Create preferences if null
if (company.Preferences == null)
{
company.Preferences = new Core.Entities.CompanyPreferences
{
CompanyId = companyId
};
}
// Parse the CSV
string? line;
bool isTableSection = false;
bool isFirstLineOfTable = false;
while ((line = await reader.ReadLineAsync()) != null)
{
lineNumber++;
// Skip comments and empty lines
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#"))
continue;
// Check for section headers
if (line.StartsWith("[") && line.EndsWith("]"))
{
currentSection = line.Trim('[', ']');
isTableSection = IsTableSection(currentSection);
isFirstLineOfTable = isTableSection;
if (isTableSection)
{
tableSections[currentSection] = new List<string[]>();
}
continue;
}
if (isTableSection)
{
// Parse CSV row
var cells = ParseCsvLine(line);
if (isFirstLineOfTable)
{
// This is the header row
tableHeaders[currentSection] = cells;
isFirstLineOfTable = false;
}
else
{
// This is a data row
tableSections[currentSection].Add(cells);
}
}
else
{
// Parse key-value pairs
var parts = line.Split(',', 2);
if (parts.Length != 2)
{
errors.Add($"Line {lineNumber}: Invalid format (expected Key,Value)");
continue;
}
var key = parts[0].Trim();
var value = parts[1].Trim();
// Skip empty values
if (string.IsNullOrWhiteSpace(value))
continue;
try
{
switch (currentSection)
{
case "Company Info":
UpdateCompanyInfo(company, key, value);
break;
case "Operating Costs":
UpdateOperatingCosts(company.OperatingCosts, key, value);
break;
case "Preferences":
UpdatePreferences(company.Preferences, key, value);
break;
}
}
catch (Exception ex)
{
errors.Add($"Line {lineNumber} ({key}): {ex.Message}");
}
}
}
// Process table sections
foreach (var section in tableSections)
{
try
{
await ProcessTableSection(section.Key, tableHeaders[section.Key], section.Value, companyId, errors);
}
catch (Exception ex)
{
errors.Add($"Section [{section.Key}]: {ex.Message}");
}
}
await _unitOfWork.CompleteAsync();
var message = errors.Any()
? $"Import completed with {errors.Count} warning(s). Settings have been updated."
: "All settings imported successfully!";
return (true, message, errors);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing company settings");
return (false, $"Import failed: {ex.Message}", errors);
}
}
/// <summary>
/// Returns true if the given CSV section name corresponds to a multi-row table (Pricing Tiers,
/// Job Statuses, etc.) rather than a flat key-value section (Company Info, Operating Costs,
/// Preferences). The distinction controls whether <see cref="ImportCompanySettingsFromCsv"/>
/// reads lines as "Key,Value" pairs or as CSV rows with a header row followed by data rows.
/// </summary>
private bool IsTableSection(string sectionName)
{
return sectionName switch
{
"Pricing Tiers" => true,
"Job Statuses" => true,
"Job Priorities" => true,
"Quote Statuses" => true,
"Inventory Categories" => true,
"Appointment Statuses" => true,
"Appointment Types" => true,
_ => false
};
}
/// <summary>
/// Parses a single CSV line into a string array, correctly handling RFC 4180 quoting rules:
/// fields enclosed in double quotes may contain commas, newlines, or literal double-quotes
/// (escaped as two consecutive double-quotes ""). Each resulting cell is trimmed of leading
/// and trailing whitespace. Used exclusively for table-section rows where a standard
/// <c>string.Split(',')</c> would break on embedded commas in field values (e.g., addresses).
/// </summary>
private string[] ParseCsvLine(string line)
{
var cells = new List<string>();
var inQuotes = false;
var currentCell = new System.Text.StringBuilder();
for (int i = 0; i < line.Length; i++)
{
var c = line[i];
if (c == '"')
{
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
{
// Escaped quote
currentCell.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}
}
else if (c == ',' && !inQuotes)
{
cells.Add(currentCell.ToString().Trim());
currentCell.Clear();
}
else
{
currentCell.Append(c);
}
}
cells.Add(currentCell.ToString().Trim());
return cells.ToArray();
}
/// <summary>
/// Dispatches table-section data to the appropriate typed import helper based on the section
/// name. Each helper receives the parsed header row (for column-index resolution) and all data
/// rows, and appends any row-level errors to the shared <paramref name="errors"/> list.
/// Adding a new importable lookup table requires adding a case here and implementing the
/// corresponding <c>Import*</c> private method.
/// </summary>
private async Task ProcessTableSection(string sectionName, string[] headers, List<string[]> rows, int companyId, List<string> errors)
{
switch (sectionName)
{
case "Pricing Tiers":
await ImportPricingTiers(headers, rows, companyId, errors);
break;
case "Job Statuses":
await ImportJobStatuses(headers, rows, companyId, errors);
break;
case "Job Priorities":
await ImportJobPriorities(headers, rows, companyId, errors);
break;
case "Quote Statuses":
await ImportQuoteStatuses(headers, rows, companyId, errors);
break;
case "Inventory Categories":
await ImportInventoryCategories(headers, rows, companyId, errors);
break;
case "Appointment Statuses":
await ImportAppointmentStatuses(headers, rows, companyId, errors);
break;
case "Appointment Types":
await ImportAppointmentTypes(headers, rows, companyId, errors);
break;
}
}
/// <summary>
/// Applies a single key-value pair from the "[Company Info]" CSV section to the given company
/// entity. Unrecognised keys are silently ignored so the file format can evolve without breaking
/// older imports. The caller is responsible for saving changes via <c>CompleteAsync</c>.
/// </summary>
private void UpdateCompanyInfo(Core.Entities.Company company, string key, string value)
{
switch (key)
{
case "CompanyName":
company.CompanyName = value;
break;
case "CompanyCode":
company.CompanyCode = value;
break;
case "PrimaryContactName":
company.PrimaryContactName = value;
break;
case "PrimaryContactEmail":
company.PrimaryContactEmail = value;
break;
case "Phone":
company.Phone = value;
break;
case "Address":
company.Address = value;
break;
case "City":
company.City = value;
break;
case "State":
company.State = value;
break;
case "ZipCode":
company.ZipCode = value;
break;
case "TimeZone":
company.TimeZone = value;
break;
}
}
/// <summary>
/// Applies a single key-value pair from the "[Operating Costs]" CSV section to the given
/// operating costs entity using <c>decimal.Parse</c>. Throws <see cref="FormatException"/> on
/// invalid numeric values; callers should catch and log this as a row-level warning without
/// aborting the entire import. Unrecognised keys are silently ignored.
/// </summary>
private void UpdateOperatingCosts(Core.Entities.CompanyOperatingCosts costs, string key, string value)
{
switch (key)
{
case "StandardLaborRate":
costs.StandardLaborRate = decimal.Parse(value);
break;
case "AdditionalCoatLaborPercent":
costs.AdditionalCoatLaborPercent = decimal.Parse(value);
break;
case "OvenOperatingCostPerHour":
costs.OvenOperatingCostPerHour = decimal.Parse(value);
break;
case "SandblasterCostPerHour":
costs.SandblasterCostPerHour = decimal.Parse(value);
break;
case "CoatingBoothCostPerHour":
costs.CoatingBoothCostPerHour = decimal.Parse(value);
break;
case "PowderCoatingCostPerSqFt":
costs.PowderCoatingCostPerSqFt = decimal.Parse(value);
break;
case "GeneralMarkupPercentage":
costs.GeneralMarkupPercentage = decimal.Parse(value);
break;
case "TaxPercent":
costs.TaxPercent = decimal.Parse(value);
break;
case "ShopSuppliesRate":
costs.ShopSuppliesRate = decimal.Parse(value);
break;
case "RushChargeType":
costs.RushChargeType = value;
break;
case "RushChargePercentage":
costs.RushChargePercentage = decimal.Parse(value);
break;
case "RushChargeFixedAmount":
costs.RushChargeFixedAmount = decimal.Parse(value);
break;
case "ShopMinimumCharge":
costs.ShopMinimumCharge = decimal.Parse(value);
break;
}
}
/// <summary>
/// Applies a single key-value pair from the "[Preferences]" CSV section to the given
/// preferences entity. Boolean fields use <c>bool.Parse</c> (case-insensitive, expects "true"
/// or "false"); integer fields use <c>int.Parse</c>. Like <see cref="UpdateOperatingCosts"/>,
/// parse failures throw and are caught by the caller as row-level warnings.
/// </summary>
private void UpdatePreferences(Core.Entities.CompanyPreferences prefs, string key, string value)
{
switch (key)
{
case "DefaultCurrency":
prefs.DefaultCurrency = value;
break;
case "DefaultDateFormat":
prefs.DefaultDateFormat = value;
break;
case "DefaultTimeFormat":
prefs.DefaultTimeFormat = value;
break;
case "DefaultPaymentTerms":
prefs.DefaultPaymentTerms = value;
break;
case "DefaultQuoteValidityDays":
prefs.DefaultQuoteValidityDays = int.Parse(value);
break;
case "QuoteNumberPrefix":
prefs.QuoteNumberPrefix = value;
break;
case "JobNumberPrefix":
prefs.JobNumberPrefix = value;
break;
case "UseMetricSystem":
prefs.UseMetricSystem = bool.Parse(value);
break;
case "DefaultJobPriority":
prefs.DefaultJobPriority = value;
break;
case "RequireCustomerPO":
prefs.RequireCustomerPO = bool.Parse(value);
break;
case "AllowCustomerApproval":
prefs.AllowCustomerApproval = bool.Parse(value);
break;
case "DefaultTurnaroundDays":
prefs.DefaultTurnaroundDays = int.Parse(value);
break;
case "EmailNotificationsEnabled":
prefs.EmailNotificationsEnabled = bool.Parse(value);
break;
case "NotifyOnNewJob":
prefs.NotifyOnNewJob = bool.Parse(value);
break;
case "NotifyOnJobStatusChange":
prefs.NotifyOnJobStatusChange = bool.Parse(value);
break;
case "NotifyOnQuoteApproval":
prefs.NotifyOnQuoteApproval = bool.Parse(value);
break;
case "NotifyOnPaymentReceived":
prefs.NotifyOnPaymentReceived = bool.Parse(value);
break;
case "QuoteExpiryWarningDays":
prefs.QuoteExpiryWarningDays = int.Parse(value);
break;
case "DueDateWarningDays":
prefs.DueDateWarningDays = int.Parse(value);
break;
case "MaintenanceAlertDays":
prefs.MaintenanceAlertDays = int.Parse(value);
break;
case "QuoteRetentionYears":
prefs.QuoteRetentionYears = int.Parse(value);
break;
case "JobRetentionYears":
prefs.JobRetentionYears = int.Parse(value);
break;
case "LogRetentionDays":
prefs.LogRetentionDays = int.Parse(value);
break;
case "AutoArchiveJobsDays":
prefs.AutoArchiveJobsDays = int.Parse(value);
break;
case "DeletedRecordRetentionDays":
prefs.DeletedRecordRetentionDays = int.Parse(value);
break;
}
}
/// <summary>
/// Replaces all existing pricing tiers for the company with the tiers defined in the CSV
/// table section. Existing records are physically deleted (not soft-deleted) because pricing
/// tiers are configuration data, not transactional records; any customers referencing deleted
/// tiers will retain their FK but the tier row will be gone. Callers should warn users that
/// table sections replace all existing records — the export/import round-trip is the safe path.
/// Column positions are resolved by name from <paramref name="headers"/> so the import is
/// resilient to column reordering.
/// </summary>
private async Task ImportPricingTiers(string[] headers, List<string[]> rows, int companyId, List<string> errors)
{
// Delete existing tiers for this company
var existing = await _unitOfWork.PricingTiers.FindAsync(pt => pt.CompanyId == companyId);
foreach (var tier in existing)
{
await _unitOfWork.PricingTiers.DeleteAsync(tier);
}
// Import new tiers
var nameIdx = Array.IndexOf(headers, "TierName");
var descIdx = Array.IndexOf(headers, "Description");
var discountIdx = Array.IndexOf(headers, "DiscountPercent");
var activeIdx = Array.IndexOf(headers, "IsActive");
foreach (var row in rows)
{
try
{
var tier = new Core.Entities.PricingTier
{
CompanyId = companyId,
TierName = row[nameIdx],
Description = descIdx >= 0 && descIdx < row.Length ? row[descIdx] : null,
DiscountPercent = discountIdx >= 0 && discountIdx < row.Length ? decimal.Parse(row[discountIdx]) : 0,
IsActive = activeIdx >= 0 && activeIdx < row.Length ? bool.Parse(row[activeIdx]) : true
};
await _unitOfWork.PricingTiers.AddAsync(tier);
}
catch (Exception ex)
{
errors.Add($"Pricing Tier '{row[nameIdx]}': {ex.Message}");
}
}
}
/// <summary>
/// Upserts job status lookup records from the CSV table section. Unlike other lookup importers,
/// this one does NOT delete all existing records first: only non-system-defined statuses are
/// deleted, and system-defined ones are updated in-place via a find-by-StatusCode lookup.
/// This protects built-in statuses (e.g., "PENDING", "COMPLETED") that jobs may already
/// reference, while still allowing custom statuses to be replaced. System-defined rows from
/// the CSV are recognised by their <c>IsSystemDefined</c> column value.
/// </summary>
private async Task ImportJobStatuses(string[] headers, List<string[]> rows, int companyId, List<string> errors)
{
// Delete existing non-system-defined statuses
var existing = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId && !s.IsSystemDefined);
foreach (var status in existing)
{
await _unitOfWork.JobStatusLookups.DeleteAsync(status);
}
// Update or add statuses
var codeIdx = Array.IndexOf(headers, "StatusCode");
var nameIdx = Array.IndexOf(headers, "DisplayName");
var orderIdx = Array.IndexOf(headers, "DisplayOrder");
var colorIdx = Array.IndexOf(headers, "ColorClass");
var iconIdx = Array.IndexOf(headers, "IconClass");
var activeIdx = Array.IndexOf(headers, "IsActive");
var systemIdx = Array.IndexOf(headers, "IsSystemDefined");
var terminalIdx = Array.IndexOf(headers, "IsTerminalStatus");
var wipIdx = Array.IndexOf(headers, "IsWorkInProgressStatus");
var categoryIdx = Array.IndexOf(headers, "WorkflowCategory");
var descIdx = Array.IndexOf(headers, "Description");
foreach (var row in rows)
{
try
{
var statusCode = row[codeIdx];
var existingStatus = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId && s.StatusCode == statusCode);
var status = existingStatus.FirstOrDefault();
if (status == null)
{
status = new Core.Entities.JobStatusLookup { CompanyId = companyId, StatusCode = statusCode };
await _unitOfWork.JobStatusLookups.AddAsync(status);
}
status.DisplayName = row[nameIdx];
status.DisplayOrder = int.Parse(row[orderIdx]);
status.ColorClass = row[colorIdx];
status.IconClass = iconIdx >= 0 && iconIdx < row.Length ? row[iconIdx] : null;
status.IsActive = bool.Parse(row[activeIdx]);
status.IsSystemDefined = bool.Parse(row[systemIdx]);
status.IsTerminalStatus = bool.Parse(row[terminalIdx]);
status.IsWorkInProgressStatus = bool.Parse(row[wipIdx]);
status.WorkflowCategory = categoryIdx >= 0 && categoryIdx < row.Length ? row[categoryIdx] : null;
status.Description = descIdx >= 0 && descIdx < row.Length ? row[descIdx] : null;
}
catch (Exception ex)
{
errors.Add($"Job Status '{row[codeIdx]}': {ex.Message}");
}
}
}
/// <summary>
/// Replaces all job priority lookup records for the company with the priorities defined in
/// the CSV table section. All existing priorities are physically deleted before inserting the
/// new set, so callers must include every priority they want to keep (including the system
/// defaults) when submitting the import file.
/// </summary>
private async Task ImportJobPriorities(string[] headers, List<string[]> rows, int companyId, List<string> errors)
{
// Delete existing priorities
var existing = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
foreach (var priority in existing)
{
await _unitOfWork.JobPriorityLookups.DeleteAsync(priority);
}
var codeIdx = Array.IndexOf(headers, "PriorityCode");
var nameIdx = Array.IndexOf(headers, "DisplayName");
var orderIdx = Array.IndexOf(headers, "DisplayOrder");
var colorIdx = Array.IndexOf(headers, "ColorClass");
var iconIdx = Array.IndexOf(headers, "IconClass");
var activeIdx = Array.IndexOf(headers, "IsActive");
foreach (var row in rows)
{
try
{
var priority = new Core.Entities.JobPriorityLookup
{
CompanyId = companyId,
PriorityCode = row[codeIdx],
DisplayName = row[nameIdx],
DisplayOrder = int.Parse(row[orderIdx]),
ColorClass = row[colorIdx],
IconClass = iconIdx >= 0 && iconIdx < row.Length ? row[iconIdx] : null,
IsActive = bool.Parse(row[activeIdx])
};
await _unitOfWork.JobPriorityLookups.AddAsync(priority);
}
catch (Exception ex)
{
errors.Add($"Job Priority '{row[codeIdx]}': {ex.Message}");
}
}
}
/// <summary>
/// Replaces all quote status lookup records for the company with the statuses defined in the
/// CSV table section. The three semantic flags (<c>IsApprovedStatus</c>, <c>IsConvertedStatus</c>,
/// <c>IsDraftStatus</c>) are critical for workflow logic (e.g., only an approved quote can be
/// converted to a job), so importers must ensure exactly one row sets each flag to true.
/// </summary>
private async Task ImportQuoteStatuses(string[] headers, List<string[]> rows, int companyId, List<string> errors)
{
// Delete existing statuses
var existing = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
foreach (var status in existing)
{
await _unitOfWork.QuoteStatusLookups.DeleteAsync(status);
}
var codeIdx = Array.IndexOf(headers, "StatusCode");
var nameIdx = Array.IndexOf(headers, "DisplayName");
var orderIdx = Array.IndexOf(headers, "DisplayOrder");
var colorIdx = Array.IndexOf(headers, "ColorClass");
var iconIdx = Array.IndexOf(headers, "IconClass");
var activeIdx = Array.IndexOf(headers, "IsActive");
var approvedIdx = Array.IndexOf(headers, "IsApprovedStatus");
var convertedIdx = Array.IndexOf(headers, "IsConvertedStatus");
var draftIdx = Array.IndexOf(headers, "IsDraftStatus");
foreach (var row in rows)
{
try
{
var status = new Core.Entities.QuoteStatusLookup
{
CompanyId = companyId,
StatusCode = row[codeIdx],
DisplayName = row[nameIdx],
DisplayOrder = int.Parse(row[orderIdx]),
ColorClass = row[colorIdx],
IconClass = iconIdx >= 0 && iconIdx < row.Length ? row[iconIdx] : null,
IsActive = bool.Parse(row[activeIdx]),
IsApprovedStatus = bool.Parse(row[approvedIdx]),
IsConvertedStatus = bool.Parse(row[convertedIdx]),
IsDraftStatus = bool.Parse(row[draftIdx])
};
await _unitOfWork.QuoteStatusLookups.AddAsync(status);
}
catch (Exception ex)
{
errors.Add($"Quote Status '{row[codeIdx]}': {ex.Message}");
}
}
}
/// <summary>
/// Replaces all inventory category lookup records for the company with the categories defined
/// in the CSV table section. The <c>IsCoating</c> flag is used by the pricing engine to
/// identify powder-coating material categories for coverage calculations; at least one category
/// with <c>IsCoating=true</c> should be present for quoting to work correctly.
/// </summary>
private async Task ImportInventoryCategories(string[] headers, List<string[]> rows, int companyId, List<string> errors)
{
// Delete existing categories
var existing = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
foreach (var category in existing)
{
await _unitOfWork.InventoryCategoryLookups.DeleteAsync(category);
}
var codeIdx = Array.IndexOf(headers, "CategoryCode");
var nameIdx = Array.IndexOf(headers, "DisplayName");
var orderIdx = Array.IndexOf(headers, "DisplayOrder");
var activeIdx = Array.IndexOf(headers, "IsActive");
var systemIdx = Array.IndexOf(headers, "IsSystemDefined");
var coatingIdx = Array.IndexOf(headers, "IsCoating");
var descIdx = Array.IndexOf(headers, "Description");
foreach (var row in rows)
{
try
{
var category = new Core.Entities.InventoryCategoryLookup
{
CompanyId = companyId,
CategoryCode = row[codeIdx],
DisplayName = row[nameIdx],
DisplayOrder = int.Parse(row[orderIdx]),
IsActive = bool.Parse(row[activeIdx]),
IsSystemDefined = systemIdx >= 0 && systemIdx < row.Length ? bool.Parse(row[systemIdx]) : false,
IsCoating = coatingIdx >= 0 && coatingIdx < row.Length ? bool.Parse(row[coatingIdx]) : false,
Description = descIdx >= 0 && descIdx < row.Length ? row[descIdx] : null
};
await _unitOfWork.InventoryCategoryLookups.AddAsync(category);
}
catch (Exception ex)
{
errors.Add($"Inventory Category '{row[codeIdx]}': {ex.Message}");
}
}
}
/// <summary>
/// Replaces all appointment status lookup records for the company with the statuses defined
/// in the CSV table section. All existing statuses are physically deleted before inserting
/// the new set; callers must include every status they wish to retain.
/// </summary>
private async Task ImportAppointmentStatuses(string[] headers, List<string[]> rows, int companyId, List<string> errors)
{
// Delete existing statuses
var existing = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId);
foreach (var status in existing)
{
await _unitOfWork.AppointmentStatusLookups.DeleteAsync(status);
}
var codeIdx = Array.IndexOf(headers, "StatusCode");
var nameIdx = Array.IndexOf(headers, "DisplayName");
var orderIdx = Array.IndexOf(headers, "DisplayOrder");
var colorIdx = Array.IndexOf(headers, "ColorClass");
var iconIdx = Array.IndexOf(headers, "IconClass");
var activeIdx = Array.IndexOf(headers, "IsActive");
foreach (var row in rows)
{
try
{
var status = new Core.Entities.AppointmentStatusLookup
{
CompanyId = companyId,
StatusCode = row[codeIdx],
DisplayName = row[nameIdx],
DisplayOrder = int.Parse(row[orderIdx]),
ColorClass = row[colorIdx],
IconClass = iconIdx >= 0 && iconIdx < row.Length ? row[iconIdx] : null,
IsActive = bool.Parse(row[activeIdx])
};
await _unitOfWork.AppointmentStatusLookups.AddAsync(status);
}
catch (Exception ex)
{
errors.Add($"Appointment Status '{row[codeIdx]}': {ex.Message}");
}
}
}
/// <summary>
/// Replaces all appointment type lookup records for the company with the types defined in the
/// CSV table section. All existing types are physically deleted before inserting the new set;
/// callers must include every type they wish to retain. The optional <c>Description</c> column
/// defaults to null if the column is absent or the cell is empty.
/// </summary>
private async Task ImportAppointmentTypes(string[] headers, List<string[]> rows, int companyId, List<string> errors)
{
// Delete existing types
var existing = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
foreach (var type in existing)
{
await _unitOfWork.AppointmentTypeLookups.DeleteAsync(type);
}
var codeIdx = Array.IndexOf(headers, "TypeCode");
var nameIdx = Array.IndexOf(headers, "DisplayName");
var orderIdx = Array.IndexOf(headers, "DisplayOrder");
var colorIdx = Array.IndexOf(headers, "ColorClass");
var iconIdx = Array.IndexOf(headers, "IconClass");
var activeIdx = Array.IndexOf(headers, "IsActive");
var descIdx = Array.IndexOf(headers, "Description");
foreach (var row in rows)
{
try
{
var type = new Core.Entities.AppointmentTypeLookup
{
CompanyId = companyId,
TypeCode = row[codeIdx],
DisplayName = row[nameIdx],
DisplayOrder = int.Parse(row[orderIdx]),
ColorClass = row[colorIdx],
IconClass = iconIdx >= 0 && iconIdx < row.Length ? row[iconIdx] : null,
IsActive = bool.Parse(row[activeIdx]),
Description = descIdx >= 0 && descIdx < row.Length ? row[descIdx] : null
};
await _unitOfWork.AppointmentTypeLookups.AddAsync(type);
}
catch (Exception ex)
{
errors.Add($"Appointment Type '{row[codeIdx]}': {ex.Message}");
}
}
}
/// <summary>
/// Builds a CSV string for the given invoice collection. Invoice status is written as its enum
/// name. Monetary fields (SubTotal, TaxAmount, DiscountAmount, Total, AmountPaid, BalanceDue)
/// are written without formatting so they can be parsed as decimals by downstream tools.
/// </summary>
private string GenerateInvoicesCsv(IEnumerable<Core.Entities.Invoice> invoices)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("InvoiceNumber,JobNumber,Customer,Status,InvoiceDate,DueDate,SubTotal,TaxPercent,TaxAmount,DiscountAmount,Total,AmountPaid,BalanceDue,CustomerPO,Terms,Notes");
foreach (var invoice in invoices)
{
var customerName = invoice.Customer != null
? (!string.IsNullOrWhiteSpace(invoice.Customer.CompanyName)
? invoice.Customer.CompanyName
: $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim())
: "Unknown";
sb.AppendLine($"{EscapeCsv(invoice.InvoiceNumber)},{EscapeCsv(invoice.Job?.JobNumber)}," +
$"{EscapeCsv(customerName)},{invoice.Status}," +
$"{invoice.InvoiceDate:yyyy-MM-dd},{invoice.DueDate?.ToString("yyyy-MM-dd")}," +
$"{invoice.SubTotal},{invoice.TaxPercent},{invoice.TaxAmount},{invoice.DiscountAmount}," +
$"{invoice.Total},{invoice.AmountPaid},{invoice.BalanceDue}," +
$"{EscapeCsv(invoice.CustomerPO)},{EscapeCsv(invoice.Terms)},{EscapeCsv(invoice.Notes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV string for the given invoice payment records. The parent invoice number is
/// resolved from the eagerly loaded <c>Invoice</c> navigation property. PaymentMethod is
/// written as its enum name (e.g., "Cash", "BankTransferACH").
/// </summary>
private string GeneratePaymentsCsv(IEnumerable<Core.Entities.Payment> payments)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,Reference,Notes");
foreach (var payment in payments)
{
sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," +
$"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," +
$"{payment.PaymentMethod},{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV string for the given purchase order collection. PO status is written as its
/// enum name. The vendor company name is resolved from the eagerly loaded <c>Vendor</c>
/// navigation property; null-safe access means POs with a missing vendor (orphaned records)
/// will output an empty vendor column rather than throwing.
/// </summary>
private string GeneratePurchaseOrdersCsv(IEnumerable<Core.Entities.PurchaseOrder> purchaseOrders)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("PoNumber,Vendor,Status,OrderDate,ExpectedDeliveryDate,ReceivedDate,SubTotal,ShippingCost,TotalAmount,Notes");
foreach (var po in purchaseOrders)
{
sb.AppendLine($"{EscapeCsv(po.PoNumber)},{EscapeCsv(po.Vendor?.CompanyName)}," +
$"{po.Status},{po.OrderDate:yyyy-MM-dd}," +
$"{po.ExpectedDeliveryDate?.ToString("yyyy-MM-dd")},{po.ReceivedDate?.ToString("yyyy-MM-dd")}," +
$"{po.SubTotal},{po.ShippingCost},{po.TotalAmount},{EscapeCsv(po.Notes)}");
}
return sb.ToString();
}
// GET: Tools/DownloadExpenseTemplate
[HttpGet]
public IActionResult DownloadExpenseTemplate()
{
var csvBytes = _csvImportService.GenerateExpenseTemplate();
return File(csvBytes, "text/csv", "expense_import_template.csv");
}
/// <summary>
/// Bulk-imports expense records from a native CSV file. Resolves ExpenseAccountNumber and
/// PaymentAccountNumber against the Chart of Accounts; VendorName and JobNumber are optional
/// lookups. ExpenseNumber is auto-generated when blank.
/// </summary>
// POST: Tools/CsvImportExpenses
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportExpenses(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing expenses from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportExpensesAsync(stream, companyId.Value);
await LogCsvImportAsync("Expenses", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
totalRows = result.TotalRows,
skippedCount = result.SkippedCount,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing expenses from CSV");
return Json(new { success = false, message = "An unexpected error occurred during import." });
}
}
// GET: Tools/DownloadChartOfAccountsTemplate
[HttpGet]
public IActionResult DownloadChartOfAccountsTemplate()
{
var csvBytes = _csvImportService.GenerateChartOfAccountsTemplate();
return File(csvBytes, "text/csv", "chart_of_accounts_import_template.csv");
}
/// <summary>
/// Bulk-imports Chart of Accounts entries from a native CSV file. Existing accounts matched by
/// AccountNumber are updated; new ones are created. System accounts are never modified.
/// Returns JSON with per-row status detail.
/// </summary>
// POST: Tools/CsvImportChartOfAccounts
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportChartOfAccounts(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing chart of accounts from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportChartOfAccountsAsync(stream, companyId.Value);
await LogCsvImportAsync("ChartOfAccounts", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing chart of accounts from CSV");
return Json(new { success = false, message = "An unexpected error occurred during import." });
}
}
/// <summary>
/// Exports all expense records for the current company as a CSV file. Account numbers are
/// written as the GL account number (e.g. "6200") so the file can be re-imported directly.
/// </summary>
// GET: Tools/ExportExpensesCsv
[HttpGet]
public async Task<IActionResult> ExportExpensesCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var expenses = await _unitOfWork.Expenses.GetAllAsync(false,
e => e.ExpenseAccount,
e => e.PaymentAccount,
e => e.Vendor,
e => e.Job);
var csv = GenerateExpensesCsv(expenses);
var fileName = $"expenses_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("Expenses", "CSV export");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting expenses to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting expenses.";
return RedirectToAction(nameof(Index));
}
}
private string GenerateExpensesCsv(IEnumerable<Core.Entities.Expense> expenses)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("ExpenseNumber,Date,VendorName,ExpenseAccountNumber,PaymentAccountNumber,JobNumber,PaymentMethod,Amount,Memo");
foreach (var e in expenses.OrderBy(x => x.Date).ThenBy(x => x.ExpenseNumber))
{
sb.AppendLine($"{EscapeCsv(e.ExpenseNumber)},{e.Date:yyyy-MM-dd},{EscapeCsv(e.Vendor?.CompanyName)},{EscapeCsv(e.ExpenseAccount?.AccountNumber)},{EscapeCsv(e.PaymentAccount?.AccountNumber)},{EscapeCsv(e.Job?.JobNumber)},{e.PaymentMethod},{e.Amount},{EscapeCsv(e.Memo)}");
}
return sb.ToString();
}
/// <summary>
/// Exports all prep services for the current company as a CSV file.
/// Column names match PrepServiceImportDto exactly so the file can be re-imported.
/// </summary>
// GET: Tools/ExportPrepServicesCsv
[HttpGet]
public async Task<IActionResult> ExportPrepServicesCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
var csv = GeneratePrepServicesCsv(prepServices);
var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("PrepServices", $"CSV export ({prepServices.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting prep services to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting prep services.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all vendor records for the current company as a CSV file.
/// Column names match <see cref="VendorImportDto"/> exactly so the file can be re-imported.
/// </summary>
// GET: Tools/ExportVendorsCsv
[HttpGet]
public async Task<IActionResult> ExportVendorsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var csv = GenerateVendorsCsv(vendors);
var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("Vendors", $"CSV export ({vendors.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting vendors to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting vendors.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports the Chart of Accounts for the current company as a CSV file.
/// Useful for reviewing all GL accounts and migrating the account list between environments.
/// </summary>
// GET: Tools/ExportChartOfAccountsCsv
[HttpGet]
public async Task<IActionResult> ExportChartOfAccountsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var csv = GenerateChartOfAccountsCsv(accounts);
var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("ChartOfAccounts", $"CSV export ({accounts.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting chart of accounts to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting chart of accounts.";
return RedirectToAction(nameof(Index));
}
}
private string GenerateVendorsCsv(IEnumerable<Core.Entities.Vendor> vendors)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("CompanyName,ContactName,Email,Phone,Address,City,State,ZipCode,Country,Website,AccountNumber,TaxId,PaymentTerms,CreditLimit,IsPreferred,IsActive,Notes");
foreach (var v in vendors)
{
sb.AppendLine($"{EscapeCsv(v.CompanyName)},{EscapeCsv(v.ContactName)},{EscapeCsv(v.Email)},{EscapeCsv(v.Phone)},{EscapeCsv(v.Address)},{EscapeCsv(v.City)},{EscapeCsv(v.State)},{EscapeCsv(v.ZipCode)},{EscapeCsv(v.Country)},{EscapeCsv(v.Website)},{EscapeCsv(v.AccountNumber)},{EscapeCsv(v.TaxId)},{EscapeCsv(v.PaymentTerms)},{v.CreditLimit},{v.IsPreferred.ToString().ToLower()},{v.IsActive.ToString().ToLower()},{EscapeCsv(v.Notes)}");
}
return sb.ToString();
}
private string GenerateChartOfAccountsCsv(IEnumerable<Core.Entities.Account> accounts)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("AccountNumber,Name,AccountType,AccountSubType,Description,IsActive,IsSystem,OpeningBalance,OpeningBalanceDate,CurrentBalance");
foreach (var a in accounts.OrderBy(x => x.AccountNumber))
{
sb.AppendLine($"{EscapeCsv(a.AccountNumber)},{EscapeCsv(a.Name)},{a.AccountType},{a.AccountSubType},{EscapeCsv(a.Description)},{a.IsActive.ToString().ToLower()},{a.IsSystem.ToString().ToLower()},{a.OpeningBalance},{a.OpeningBalanceDate?.ToString("yyyy-MM-dd")},{a.CurrentBalance}");
}
return sb.ToString();
}
private string EscapeCsv(string? value)
{
if (string.IsNullOrEmpty(value))
return "";
if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r"))
{
return $"\"{value.Replace("\"", "\"\"")}\"";
}
return value;
}
#endregion
// ── QuickBooks Online Import Endpoints ────────────────────────────────────
/// <summary>
/// Imports customers from a QuickBooks Online CSV export file. Delegates all parsing and
/// upsert logic to <see cref="PowderCoating.Web.Services.QuickBooksOnlineService"/> via the
/// shared <see cref="RunQboImport"/> helper. The QBO service is injected per-action via
/// <c>[FromServices]</c> to avoid adding it to the controller constructor, keeping the
/// constructor lean given the number of other injected services.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQboCustomers(IFormFile file,
[FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService)
=> await RunQboImport(file, qboService.ImportCustomersAsync, "customers");
/// <summary>
/// Imports vendors from a QuickBooks Online CSV export file via <see cref="RunQboImport"/>.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQboVendors(IFormFile file,
[FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService)
=> await RunQboImport(file, qboService.ImportVendorsAsync, "vendors");
/// <summary>
/// Imports Products &amp; Services (catalog items) from a QuickBooks Online CSV export file
/// via <see cref="RunQboImport"/>.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQboCatalogItems(IFormFile file,
[FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService)
=> await RunQboImport(file, qboService.ImportCatalogItemsAsync, "products & services");
/// <summary>
/// Imports the Chart of Accounts from a QuickBooks Online CSV export file via
/// <see cref="RunQboImport"/>.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQboChartOfAccounts(IFormFile file,
[FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService)
=> await RunQboImport(file, qboService.ImportChartOfAccountsAsync, "chart of accounts");
/// <summary>
/// Imports historical invoices from a QuickBooks Online CSV export file via
/// <see cref="RunQboImport"/>.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQboInvoices(IFormFile file,
[FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService)
=> await RunQboImport(file, qboService.ImportInvoicesAsync, "invoices");
/// <summary>
/// Imports general-ledger transactions from a QuickBooks Online CSV export file via
/// <see cref="RunQboImport"/>.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ImportQboTransactions(IFormFile file,
[FromServices] PowderCoating.Web.Services.QuickBooksOnlineService qboService)
=> await RunQboImport(file, qboService.ImportTransactionsAsync, "transactions");
/// <summary>
/// Shared execution kernel for all QuickBooks Online CSV import actions. Validates the
/// company context and file presence, invokes the provided <paramref name="handler"/> delegate
/// (which is the specific QBO service method for the entity type being imported), and returns
/// a consistent JSON response with row-level error detail. The <paramref name="label"/> string
/// is used only for structured logging so log queries can filter by entity type without
/// parsing the file name. All QBO import actions are thin wrappers that delegate here to avoid
/// repeating the same validation and JSON shaping code.
/// </summary>
private async Task<IActionResult> RunQboImport(
IFormFile file,
Func<IFormFile, int, string, Task<PowderCoating.Application.DTOs.QuickBooks.ImportResultDto>> handler,
string label)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_logger.LogInformation("User {UserName} importing QBO {Label} from {FileName} for company {CompanyId}",
User.Identity?.Name, label, file.FileName, companyId);
var result = await handler(file, companyId.Value, userId);
await LogImportAsync(label, file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Success ? $"Import completed successfully!" : "Import completed with errors.",
totalRecords = result.TotalRecords,
importedCount = result.ImportedCount,
updatedCount = result.UpdatedCount,
skippedCount = result.SkippedCount,
errors = result.Errors.Select(e => new
{
severity = e.Severity,
lineNumber = e.LineNumber,
recordName = e.RecordName,
fieldName = e.FieldName,
errorMessage = e.ErrorMessage,
displayMessage = e.DisplayMessage
}).ToList()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing QBO {Label}", label);
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
}