1a44133a63
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs, mappings, controllers, views, and import/export paths. Worker identity is now handled entirely through ApplicationUser with per-user LaborCostPerHour. ShopWorkerRoleCosts table remains in production pending manual data migration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4329 lines
195 KiB
C#
4329 lines
195 KiB
C#
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 & 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 & 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}" });
|
||
}
|
||
}
|
||
}
|