Preserve accounting linkages through CSV export/import round-trip
The data export was silently dropping account linkages, so an export->import (used to copy prod data) lost which bank account each payment hit, all invoice line items, and any bill/deposit/journal-entry detail. Diagnosed while cleaning up a company's books from a copied dataset. Now every accounting linkage travels by account/vendor/customer/invoice number — matching how expenses already worked — so a round-trip preserves GL attribution instead of dropping it. Payments: add DepositAccountNumber to the export + DTO; import resolves it back to DepositAccountId so payments post to the right bank account on recalc. Invoices: were header-only (re-imported invoices had 0 line items). Add a new invoice_items CSV (one row per line, carrying RevenueAccountNumber) with export, idempotent import, UI cards, and a template. Bills / Deposits / Journal Entries: were not exported at all. Add full-fidelity export + import including line-item children — bills + bill line items (vendor by name, AP account + per-line expense account by number), deposits (customer + bank account + applied invoice), and journal entries + JE lines (account by number, debit/credit). 5 DTOs, 5 importers, 5 exporters + actions, all added to the all_data export zip, plus 10 import/export UI cards. Shared RunCsvImport helper added for the new import endpoints. All linkages resolve by stable business keys (numbers/names), never internal IDs, so the files round-trip across databases. Build clean; 278 unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing vendor bill headers from CSV. Column names match the native bills export
|
||||
/// (ExportBillsCsv) for round-trip compatibility. The vendor is resolved by name and the AP account
|
||||
/// by number so accounting linkages survive. Line items import separately via BillLineItemImportDto.
|
||||
/// </summary>
|
||||
public class BillImportDto
|
||||
{
|
||||
[Name("BillNumber")]
|
||||
public string? BillNumber { get; set; }
|
||||
|
||||
[Name("VendorInvoiceNumber")]
|
||||
public string? VendorInvoiceNumber { get; set; }
|
||||
|
||||
/// <summary>Vendor company name, matched against Vendor.CompanyName.</summary>
|
||||
[Name("VendorName")]
|
||||
public string? VendorName { get; set; }
|
||||
|
||||
/// <summary>AP account number (Chart of Accounts) this bill posts to.</summary>
|
||||
[Name("APAccountNumber")]
|
||||
public string? APAccountNumber { get; set; }
|
||||
|
||||
[Name("BillDate")]
|
||||
public DateTime BillDate { get; set; }
|
||||
|
||||
[Name("DueDate")]
|
||||
public DateTime? DueDate { get; set; }
|
||||
|
||||
[Name("Status")]
|
||||
public string Status { get; set; } = "Open";
|
||||
|
||||
[Name("Terms")]
|
||||
public string? Terms { get; set; }
|
||||
|
||||
[Name("Memo")]
|
||||
public string? Memo { get; set; }
|
||||
|
||||
[Name("SubTotal")]
|
||||
public decimal SubTotal { get; set; }
|
||||
|
||||
[Name("TaxPercent")]
|
||||
public decimal TaxPercent { get; set; }
|
||||
|
||||
[Name("TaxAmount")]
|
||||
public decimal TaxAmount { get; set; }
|
||||
|
||||
[Name("Total")]
|
||||
public decimal Total { get; set; }
|
||||
|
||||
[Name("AmountPaid")]
|
||||
public decimal AmountPaid { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing vendor bill line items from CSV. Column names match the native bill-items export
|
||||
/// (ExportBillLineItemsCsv). Lines are matched to their parent bill by BillNumber; the expense/asset
|
||||
/// account is resolved (optional) from AccountNumber so each line's GL attribution round-trips.
|
||||
/// </summary>
|
||||
public class BillLineItemImportDto
|
||||
{
|
||||
[Name("BillNumber")]
|
||||
public string? BillNumber { get; set; }
|
||||
|
||||
/// <summary>Expense/asset account number this line is categorized under. Optional.</summary>
|
||||
[Name("AccountNumber")]
|
||||
public string? AccountNumber { get; set; }
|
||||
|
||||
/// <summary>Optional job-costing link, matched against Job.JobNumber.</summary>
|
||||
[Name("JobNumber")]
|
||||
public string? JobNumber { get; set; }
|
||||
|
||||
[Name("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Name("Quantity")]
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
[Name("UnitPrice")]
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
[Name("Amount")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Name("DisplayOrder")]
|
||||
public int DisplayOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing customer deposits from CSV. Column names match the native deposits export
|
||||
/// (ExportDepositsCsv). The customer is resolved by name, the bank account by number
|
||||
/// (DepositAccountNumber), and the optional applied invoice by number so the deposit's linkages
|
||||
/// survive an export/import round-trip.
|
||||
/// </summary>
|
||||
public class DepositImportDto
|
||||
{
|
||||
[Name("ReceiptNumber")]
|
||||
public string? ReceiptNumber { get; set; }
|
||||
|
||||
/// <summary>Customer name (company name, or contact full name), matched against the customer record.</summary>
|
||||
[Name("CustomerName")]
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
[Name("Amount")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>Valid values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment</summary>
|
||||
[Name("PaymentMethod")]
|
||||
public string PaymentMethod { get; set; } = "Cash";
|
||||
|
||||
[Name("ReceivedDate")]
|
||||
public DateTime ReceivedDate { get; set; }
|
||||
|
||||
/// <summary>Bank/cash account number (Chart of Accounts) the deposit landed in. Optional.</summary>
|
||||
[Name("DepositAccountNumber")]
|
||||
public string? DepositAccountNumber { get; set; }
|
||||
|
||||
/// <summary>Invoice number this deposit has been applied to, if any. Optional.</summary>
|
||||
[Name("AppliedToInvoiceNumber")]
|
||||
public string? AppliedToInvoiceNumber { get; set; }
|
||||
|
||||
[Name("AppliedDate")]
|
||||
public DateTime? AppliedDate { get; set; }
|
||||
|
||||
[Name("Reference")]
|
||||
public string? Reference { get; set; }
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing invoice line items from CSV. Column names match the native
|
||||
/// invoice-items export (ExportInvoiceItemsCsv) for round-trip compatibility.
|
||||
/// Line items are matched to their parent invoice by <c>InvoiceNumber</c>; the revenue
|
||||
/// account is resolved from <c>RevenueAccountNumber</c> against Account.AccountNumber so the
|
||||
/// invoice's revenue attribution survives an export/import round-trip.
|
||||
/// </summary>
|
||||
public class InvoiceItemImportDto
|
||||
{
|
||||
[Name("InvoiceNumber")]
|
||||
public string? InvoiceNumber { get; set; }
|
||||
|
||||
[Name("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Name("Quantity")]
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
[Name("UnitPrice")]
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
[Name("TotalPrice")]
|
||||
public decimal TotalPrice { get; set; }
|
||||
|
||||
[Name("ColorName")]
|
||||
public string? ColorName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account number (Chart of Accounts) of the revenue account this line posts to. Optional —
|
||||
/// a blank value means the line falls back to the company's default revenue account.
|
||||
/// </summary>
|
||||
[Name("RevenueAccountNumber")]
|
||||
public string? RevenueAccountNumber { get; set; }
|
||||
|
||||
[Name("DisplayOrder")]
|
||||
public int DisplayOrder { get; set; }
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing journal entry headers from CSV. Column names match the native journal-entries
|
||||
/// export (ExportJournalEntriesCsv). The debit/credit lines import separately via
|
||||
/// JournalEntryLineImportDto and must balance per entry.
|
||||
/// </summary>
|
||||
public class JournalEntryImportDto
|
||||
{
|
||||
[Name("EntryNumber")]
|
||||
public string? EntryNumber { get; set; }
|
||||
|
||||
[Name("EntryDate")]
|
||||
public DateTime EntryDate { get; set; }
|
||||
|
||||
[Name("Reference")]
|
||||
public string? Reference { get; set; }
|
||||
|
||||
[Name("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Valid values: Draft, Posted, Reversed</summary>
|
||||
[Name("Status")]
|
||||
public string Status { get; set; } = "Draft";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing journal entry lines from CSV. Column names match the native journal-entry-lines
|
||||
/// export (ExportJournalEntryLinesCsv). Lines are matched to their parent entry by EntryNumber and the
|
||||
/// account is resolved from AccountNumber (required — a JE line is meaningless without its account).
|
||||
/// Either DebitAmount or CreditAmount is non-zero per line, not both.
|
||||
/// </summary>
|
||||
public class JournalEntryLineImportDto
|
||||
{
|
||||
[Name("EntryNumber")]
|
||||
public string? EntryNumber { get; set; }
|
||||
|
||||
/// <summary>Account number (Chart of Accounts) this line debits or credits. Required.</summary>
|
||||
[Name("AccountNumber")]
|
||||
public string? AccountNumber { get; set; }
|
||||
|
||||
[Name("DebitAmount")]
|
||||
public decimal DebitAmount { get; set; }
|
||||
|
||||
[Name("CreditAmount")]
|
||||
public decimal CreditAmount { get; set; }
|
||||
|
||||
[Name("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Name("LineOrder")]
|
||||
public int LineOrder { get; set; }
|
||||
}
|
||||
@@ -24,6 +24,14 @@ public class PaymentImportDto
|
||||
[Name("PaymentMethod")]
|
||||
public string PaymentMethod { get; set; } = "Cash";
|
||||
|
||||
/// <summary>
|
||||
/// Account number (Chart of Accounts) of the bank/cash account the payment was deposited into.
|
||||
/// Resolved back to <c>DepositAccountId</c> on import so the balance recalc can post it to the
|
||||
/// right bank account. Optional — a blank value means no deposit account was recorded.
|
||||
/// </summary>
|
||||
[Name("DepositAccountNumber")]
|
||||
public string? DepositAccountNumber { get; set; }
|
||||
|
||||
[Name("Reference")]
|
||||
public string? Reference { get; set; }
|
||||
|
||||
|
||||
@@ -179,10 +179,36 @@ public interface ICsvImportService
|
||||
/// <summary>
|
||||
/// Import invoice headers from a CSV stream. Customers are resolved by CustomerEmail then
|
||||
/// CustomerName. Duplicate detection uses InvoiceNumber as the unique key. Existing invoices
|
||||
/// are updated; new ones are created. Line items are not part of the CSV format.
|
||||
/// are updated; new ones are created. Line items are imported separately via
|
||||
/// <see cref="ImportInvoiceItemsAsync"/>.
|
||||
/// </summary>
|
||||
Task<CsvImportResultDto> ImportInvoicesAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Import invoice line items from a CSV stream. Each line is matched to its parent invoice by
|
||||
/// InvoiceNumber and its revenue account resolved (optional) from RevenueAccountNumber. Idempotent
|
||||
/// by description + total + display order. Run after invoices have been imported.
|
||||
/// </summary>
|
||||
Task<CsvImportResultDto> ImportInvoiceItemsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Import vendor bill headers. Vendor by name, AP account by number. Dedup by BillNumber.</summary>
|
||||
Task<CsvImportResultDto> ImportBillsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Import vendor bill line items. Matched to bills by BillNumber; account/job by number.</summary>
|
||||
Task<CsvImportResultDto> ImportBillLineItemsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Import customer deposits. Customer by name, bank account by number, applied invoice by number.</summary>
|
||||
Task<CsvImportResultDto> ImportDepositsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Import journal entry headers. Dedup by EntryNumber. Lines import separately.</summary>
|
||||
Task<CsvImportResultDto> ImportJournalEntriesAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Import journal entry lines. Matched to entries by EntryNumber; account by number (required).</summary>
|
||||
Task<CsvImportResultDto> ImportJournalEntryLinesAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Generate a CSV template file for invoice line-item imports.</summary>
|
||||
byte[] GenerateInvoiceItemTemplate();
|
||||
|
||||
/// <summary>Generate a CSV template file for payment imports.</summary>
|
||||
byte[] GeneratePaymentTemplate();
|
||||
|
||||
|
||||
@@ -3208,6 +3208,33 @@ public class CsvImportService : ICsvImportService
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] GenerateInvoiceItemTemplate()
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using var writer = new StreamWriter(memoryStream);
|
||||
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
|
||||
|
||||
csv.WriteHeader<InvoiceItemImportDto>();
|
||||
csv.NextRecord();
|
||||
|
||||
csv.WriteRecord(new InvoiceItemImportDto
|
||||
{
|
||||
InvoiceNumber = "INV-2601-0001",
|
||||
Description = "Powder coating - 4 wheels",
|
||||
Quantity = 4,
|
||||
UnitPrice = 75.00m,
|
||||
TotalPrice = 300.00m,
|
||||
ColorName = "Gloss Black",
|
||||
RevenueAccountNumber = "47905",
|
||||
DisplayOrder = 0,
|
||||
Notes = ""
|
||||
});
|
||||
csv.NextRecord();
|
||||
|
||||
writer.Flush();
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
public byte[] GeneratePaymentTemplate()
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
@@ -3219,12 +3246,13 @@ public class CsvImportService : ICsvImportService
|
||||
|
||||
csv.WriteRecord(new PaymentImportDto
|
||||
{
|
||||
InvoiceNumber = "INV-2601-0001",
|
||||
Amount = 250.00m,
|
||||
PaymentDate = DateTime.Today,
|
||||
PaymentMethod = "Check",
|
||||
Reference = "CHK-1234",
|
||||
Notes = ""
|
||||
InvoiceNumber = "INV-2601-0001",
|
||||
Amount = 250.00m,
|
||||
PaymentDate = DateTime.Today,
|
||||
PaymentMethod = "Check",
|
||||
DepositAccountNumber = "10100",
|
||||
Reference = "CHK-1234",
|
||||
Notes = ""
|
||||
});
|
||||
csv.NextRecord();
|
||||
|
||||
@@ -3232,6 +3260,651 @@ public class CsvImportService : ICsvImportService
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports invoice line items from CSV. Each row is matched to its parent invoice by InvoiceNumber;
|
||||
/// the revenue account is resolved (optional) from RevenueAccountNumber against the Chart of Accounts.
|
||||
/// Idempotent — an invoice line with the same description + total + display order is skipped, so the
|
||||
/// import can be safely re-run. Run AFTER invoices have been imported (the parents must exist).
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportInvoiceItemsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<InvoiceItemImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
_logger.LogInformation("Starting import of {Count} invoice line items for company {CompanyId}", records.Count, companyId);
|
||||
|
||||
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.InvoiceItems);
|
||||
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts
|
||||
.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.InvoiceNumber))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!invoiceByNumber.TryGetValue(record.InvoiceNumber.Trim(), out var invoice))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Invoice '{record.InvoiceNumber}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var description = StripQuotes(record.Description)?.Trim() ?? "";
|
||||
|
||||
// Idempotency: skip a line that already exists on this invoice.
|
||||
var isDuplicate = invoice.InvoiceItems.Any(it =>
|
||||
string.Equals(it.Description, description, StringComparison.OrdinalIgnoreCase)
|
||||
&& it.TotalPrice == record.TotalPrice
|
||||
&& it.DisplayOrder == record.DisplayOrder);
|
||||
if (isDuplicate)
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on invoice '{record.InvoiceNumber}' — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the optional revenue account by number so revenue attribution is preserved.
|
||||
int? revenueAccountId = null;
|
||||
var cleanRevenueAccount = StripQuotes(record.RevenueAccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanRevenueAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanRevenueAccount, out var revenueAccount))
|
||||
revenueAccountId = revenueAccount.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Revenue account '{cleanRevenueAccount}' not found in Chart of Accounts — line imported without a revenue account.");
|
||||
}
|
||||
|
||||
var item = new Core.Entities.InvoiceItem
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
CompanyId = companyId,
|
||||
Description = description,
|
||||
Quantity = record.Quantity,
|
||||
UnitPrice = record.UnitPrice,
|
||||
TotalPrice = record.TotalPrice,
|
||||
ColorName = string.IsNullOrWhiteSpace(record.ColorName) ? null : record.ColorName.Trim(),
|
||||
RevenueAccountId = revenueAccountId,
|
||||
DisplayOrder = record.DisplayOrder,
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.InvoiceItems.AddAsync(item);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Keep the in-memory invoice current so later rows dedup against it correctly.
|
||||
invoice.InvoiceItems.Add(item);
|
||||
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during invoice item CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports vendor bill headers from CSV. Vendor is resolved by name and the AP account by number.
|
||||
/// Dedup by BillNumber. Line items import separately via <see cref="ImportBillLineItemsAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportBillsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<BillImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorByName = vendors.Where(v => !string.IsNullOrEmpty(v.CompanyName))
|
||||
.GroupBy(v => v.CompanyName.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var existingBills = await _unitOfWork.Bills.GetAllAsync();
|
||||
var existingBillNumbers = existingBills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
|
||||
.Select(b => b.BillNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(billNumber))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: BillNumber is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
if (existingBillNumbers.Contains(billNumber))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Bill '{billNumber}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanVendor = StripQuotes(record.VendorName)?.Trim() ?? "";
|
||||
if (!vendorByName.TryGetValue(cleanVendor, out var vendor))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Vendor '{cleanVendor}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanApAccount = StripQuotes(record.APAccountNumber)?.Trim() ?? "";
|
||||
if (!accountByNumber.TryGetValue(cleanApAccount, out var apAccount))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: AP account '{cleanApAccount}' not found in Chart of Accounts.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<BillStatus>(record.Status?.Trim(), true, out var status))
|
||||
status = BillStatus.Open;
|
||||
|
||||
var bill = new Core.Entities.Bill
|
||||
{
|
||||
CompanyId = companyId,
|
||||
BillNumber = billNumber,
|
||||
VendorInvoiceNumber = string.IsNullOrWhiteSpace(record.VendorInvoiceNumber) ? null : record.VendorInvoiceNumber.Trim(),
|
||||
VendorId = vendor.Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = record.BillDate == default ? DateTime.UtcNow.Date : record.BillDate,
|
||||
DueDate = record.DueDate,
|
||||
Status = status,
|
||||
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
|
||||
Memo = string.IsNullOrWhiteSpace(record.Memo) ? null : record.Memo.Trim(),
|
||||
SubTotal = record.SubTotal,
|
||||
TaxPercent = record.TaxPercent,
|
||||
TaxAmount = record.TaxAmount,
|
||||
Total = record.Total,
|
||||
AmountPaid = record.AmountPaid
|
||||
};
|
||||
|
||||
await _unitOfWork.Bills.AddAsync(bill);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
existingBillNumbers.Add(billNumber);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during bill CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports vendor bill line items from CSV. Each line is matched to its parent bill by BillNumber;
|
||||
/// the expense/asset account (optional) and job (optional) are resolved by number. Idempotent by
|
||||
/// bill + description + amount + display order. Run after bills have been imported.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportBillLineItemsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<BillLineItemImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var bills = await _unitOfWork.Bills.GetAllAsync(false, b => b.LineItems);
|
||||
var billByNumber = bills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
|
||||
.ToDictionary(b => b.BillNumber.Trim(), b => b, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var jobs = await _unitOfWork.Jobs.GetAllAsync();
|
||||
var jobByNumber = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
||||
.ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
|
||||
if (!billByNumber.TryGetValue(billNumber, out var bill))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Bill '{record.BillNumber}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var description = StripQuotes(record.Description)?.Trim() ?? "";
|
||||
var isDuplicate = bill.LineItems.Any(li =>
|
||||
string.Equals(li.Description, description, StringComparison.OrdinalIgnoreCase)
|
||||
&& li.Amount == record.Amount && li.DisplayOrder == record.DisplayOrder);
|
||||
if (isDuplicate)
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on bill '{billNumber}' — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int? accountId = null;
|
||||
var cleanAccount = StripQuotes(record.AccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanAccount, out var account))
|
||||
accountId = account.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Account '{cleanAccount}' not found — line imported without an account.");
|
||||
}
|
||||
|
||||
int? jobId = null;
|
||||
var cleanJob = StripQuotes(record.JobNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanJob) && jobByNumber.TryGetValue(cleanJob, out var job))
|
||||
jobId = job.Id;
|
||||
|
||||
var lineItem = new Core.Entities.BillLineItem
|
||||
{
|
||||
CompanyId = companyId,
|
||||
BillId = bill.Id,
|
||||
AccountId = accountId,
|
||||
JobId = jobId,
|
||||
Description = description,
|
||||
Quantity = record.Quantity,
|
||||
UnitPrice = record.UnitPrice,
|
||||
Amount = record.Amount,
|
||||
DisplayOrder = record.DisplayOrder
|
||||
};
|
||||
|
||||
await _unitOfWork.BillLineItems.AddAsync(lineItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
bill.LineItems.Add(lineItem);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during bill line item CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports customer deposits from CSV. Customer is resolved by name, the bank account by number,
|
||||
/// and the optional applied invoice by number. Dedup by ReceiptNumber.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportDepositsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<DepositImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||
var customerByName = new Dictionary<string, Core.Entities.Customer>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var c in customers)
|
||||
{
|
||||
var name = !string.IsNullOrWhiteSpace(c.CompanyName)
|
||||
? c.CompanyName.Trim()
|
||||
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(name)) customerByName[name] = c;
|
||||
}
|
||||
|
||||
var invoices = await _unitOfWork.Invoices.GetAllAsync();
|
||||
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var validMethods = Enum.GetNames<PaymentMethod>()
|
||||
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var existingReceipts = (await _unitOfWork.Deposits.GetAllAsync())
|
||||
.Where(d => !string.IsNullOrEmpty(d.ReceiptNumber))
|
||||
.Select(d => d.ReceiptNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var receiptNumber = StripQuotes(record.ReceiptNumber)?.Trim() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(receiptNumber) && existingReceipts.Contains(receiptNumber))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Deposit '{receiptNumber}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanCustomer = StripQuotes(record.CustomerName)?.Trim() ?? "";
|
||||
if (!customerByName.TryGetValue(cleanCustomer, out var customer))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Customer '{cleanCustomer}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validMethods.TryGetValue(record.PaymentMethod?.Trim() ?? "", out var method))
|
||||
method = PaymentMethod.Cash;
|
||||
|
||||
int? depositAccountId = null;
|
||||
var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
|
||||
depositAccountId = depositAccount.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found — deposit imported without a bank account.");
|
||||
}
|
||||
|
||||
int? appliedInvoiceId = null;
|
||||
var cleanInvoice = StripQuotes(record.AppliedToInvoiceNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanInvoice) && invoiceByNumber.TryGetValue(cleanInvoice, out var appliedInvoice))
|
||||
appliedInvoiceId = appliedInvoice.Id;
|
||||
|
||||
var deposit = new Core.Entities.Deposit
|
||||
{
|
||||
CompanyId = companyId,
|
||||
ReceiptNumber = receiptNumber,
|
||||
CustomerId = customer.Id,
|
||||
Amount = record.Amount,
|
||||
PaymentMethod = method,
|
||||
ReceivedDate = record.ReceivedDate == default ? DateTime.UtcNow.Date : record.ReceivedDate,
|
||||
DepositAccountId = depositAccountId,
|
||||
AppliedToInvoiceId = appliedInvoiceId,
|
||||
AppliedDate = record.AppliedDate,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.Deposits.AddAsync(deposit);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
if (!string.IsNullOrWhiteSpace(receiptNumber)) existingReceipts.Add(receiptNumber);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during deposit CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports journal entry headers from CSV. Dedup by EntryNumber. The debit/credit lines import
|
||||
/// separately via <see cref="ImportJournalEntryLinesAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportJournalEntriesAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<JournalEntryImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var existingNumbers = (await _unitOfWork.JournalEntries.GetAllAsync())
|
||||
.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
|
||||
.Select(j => j.EntryNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(entryNumber))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: EntryNumber is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
if (existingNumbers.Contains(entryNumber))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Journal entry '{entryNumber}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<JournalEntryStatus>(record.Status?.Trim(), true, out var status))
|
||||
status = JournalEntryStatus.Draft;
|
||||
|
||||
var entry = new Core.Entities.JournalEntry
|
||||
{
|
||||
CompanyId = companyId,
|
||||
EntryNumber = entryNumber,
|
||||
EntryDate = record.EntryDate == default ? DateTime.UtcNow.Date : record.EntryDate,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
|
||||
Status = status,
|
||||
PostedAt = status == JournalEntryStatus.Posted ? DateTime.UtcNow : null
|
||||
};
|
||||
|
||||
await _unitOfWork.JournalEntries.AddAsync(entry);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
existingNumbers.Add(entryNumber);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during journal entry CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports journal entry lines from CSV. Each line is matched to its parent entry by EntryNumber
|
||||
/// and the account resolved (required) from AccountNumber. Idempotent by entry + account + amounts
|
||||
/// + line order. Run after journal entry headers have been imported.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportJournalEntryLinesAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<JournalEntryLineImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var entries = await _unitOfWork.JournalEntries.GetAllAsync(false, j => j.Lines);
|
||||
var entryByNumber = entries.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
|
||||
.ToDictionary(j => j.EntryNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
|
||||
if (!entryByNumber.TryGetValue(entryNumber, out var entry))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Journal entry '{record.EntryNumber}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanAccount = StripQuotes(record.AccountNumber)?.Trim() ?? "";
|
||||
if (!accountByNumber.TryGetValue(cleanAccount, out var account))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Account '{cleanAccount}' not found in Chart of Accounts.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var isDuplicate = entry.Lines.Any(l =>
|
||||
l.AccountId == account.Id && l.DebitAmount == record.DebitAmount
|
||||
&& l.CreditAmount == record.CreditAmount && l.LineOrder == record.LineOrder);
|
||||
if (isDuplicate)
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Line for account '{cleanAccount}' already exists on entry '{entryNumber}' — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var line = new Core.Entities.JournalEntryLine
|
||||
{
|
||||
CompanyId = companyId,
|
||||
JournalEntryId = entry.Id,
|
||||
AccountId = account.Id,
|
||||
DebitAmount = record.DebitAmount,
|
||||
CreditAmount = record.CreditAmount,
|
||||
Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
|
||||
LineOrder = record.LineOrder
|
||||
};
|
||||
|
||||
await _unitOfWork.JournalEntryLines.AddAsync(line);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
entry.Lines.Add(line);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during journal entry line CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CsvImportResultDto> ImportPaymentsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
@@ -3256,6 +3929,14 @@ public class CsvImportService : ICsvImportService
|
||||
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Account lookup for resolving the deposit (bank) account by number — optional per row.
|
||||
// Mirrors the expense import so payments round-trip with their bank-account linkage intact.
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts
|
||||
.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var validMethods = Enum.GetNames<PaymentMethod>()
|
||||
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -3301,15 +3982,28 @@ public class CsvImportService : ICsvImportService
|
||||
method = PaymentMethod.Cash;
|
||||
}
|
||||
|
||||
// Resolve the optional deposit (bank) account by number so the balance recalc can
|
||||
// post this payment. A blank value is fine; an unknown number warns but still imports.
|
||||
int? depositAccountId = null;
|
||||
var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
|
||||
depositAccountId = depositAccount.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found in Chart of Accounts — payment imported without a deposit account.");
|
||||
}
|
||||
|
||||
var payment = new Core.Entities.Payment
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
CompanyId = companyId,
|
||||
Amount = record.Amount,
|
||||
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
|
||||
PaymentMethod = method,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
InvoiceId = invoice.Id,
|
||||
CompanyId = companyId,
|
||||
Amount = record.Amount,
|
||||
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
|
||||
PaymentMethod = method,
|
||||
DepositAccountId = depositAccountId,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.Payments.AddAsync(payment);
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Import;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
@@ -1394,6 +1395,53 @@ public class ToolsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-imports invoice line items from a native CSV file. Lines are matched to their parent
|
||||
/// invoice by InvoiceNumber and revenue accounts resolved by number. Run after the invoice import.
|
||||
/// </summary>
|
||||
// POST: Tools/CsvImportInvoiceItems
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CsvImportInvoiceItems(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 invoice items from CSV {FileName} for company {CompanyId}",
|
||||
User.Identity?.Name, file.FileName, companyId);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _csvImportService.ImportInvoiceItemsAsync(stream, companyId.Value);
|
||||
await LogCsvImportAsync("InvoiceItems", 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 invoice 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 payment bulk import.
|
||||
/// Columns match the native ExportPaymentsCsv output for round-trip compatibility.
|
||||
@@ -1406,6 +1454,90 @@ public class ToolsController : Controller
|
||||
return File(csvBytes, "text/csv", "payment_import_template.csv");
|
||||
}
|
||||
|
||||
/// <summary>Downloads a blank CSV template for the invoice line-item bulk import.</summary>
|
||||
// GET: Tools/DownloadInvoiceItemTemplate
|
||||
[HttpGet]
|
||||
public IActionResult DownloadInvoiceItemTemplate()
|
||||
{
|
||||
var csvBytes = _csvImportService.GenerateInvoiceItemTemplate();
|
||||
return File(csvBytes, "text/csv", "invoice_item_import_template.csv");
|
||||
}
|
||||
|
||||
// POST: Tools/CsvImportBills — vendor bill headers (vendor by name, AP account by number).
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public Task<IActionResult> CsvImportBills(IFormFile file)
|
||||
=> RunCsvImport(file, "Bills", _csvImportService.ImportBillsAsync);
|
||||
|
||||
// POST: Tools/CsvImportBillLineItems — bill lines (run after bills).
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public Task<IActionResult> CsvImportBillLineItems(IFormFile file)
|
||||
=> RunCsvImport(file, "BillLineItems", _csvImportService.ImportBillLineItemsAsync);
|
||||
|
||||
// POST: Tools/CsvImportDeposits — customer deposits (customer by name, bank account by number).
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public Task<IActionResult> CsvImportDeposits(IFormFile file)
|
||||
=> RunCsvImport(file, "Deposits", _csvImportService.ImportDepositsAsync);
|
||||
|
||||
// POST: Tools/CsvImportJournalEntries — journal entry headers.
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public Task<IActionResult> CsvImportJournalEntries(IFormFile file)
|
||||
=> RunCsvImport(file, "JournalEntries", _csvImportService.ImportJournalEntriesAsync);
|
||||
|
||||
// POST: Tools/CsvImportJournalEntryLines — journal entry lines (run after journal entries).
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public Task<IActionResult> CsvImportJournalEntryLines(IFormFile file)
|
||||
=> RunCsvImport(file, "JournalEntryLines", _csvImportService.ImportJournalEntryLinesAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Shared plumbing for the accounting CSV imports: validates the upload, resolves the company,
|
||||
/// runs the given import function, logs it, and returns the standard JSON result shape.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> RunCsvImport(IFormFile file, string label,
|
||||
Func<Stream, int, Task<CsvImportResultDto>> import)
|
||||
{
|
||||
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 {Label} from CSV {FileName} for company {CompanyId}",
|
||||
User.Identity?.Name, label, file.FileName, companyId);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await import(stream, companyId.Value);
|
||||
await LogCsvImportAsync(label, 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 {Label} from CSV", label);
|
||||
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -2044,7 +2176,7 @@ public class ToolsController : Controller
|
||||
}
|
||||
|
||||
// 11. Invoices
|
||||
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job);
|
||||
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job, i => i.InvoiceItems);
|
||||
var invoicesCsv = GenerateInvoicesCsv(invoices);
|
||||
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
|
||||
using (var entryStream = invoicesEntry.Open())
|
||||
@@ -2063,6 +2195,17 @@ public class ToolsController : Controller
|
||||
await writer.WriteAsync(accountsCsv);
|
||||
}
|
||||
|
||||
// 12b. Invoice line items — one row per line, carrying the revenue account number so
|
||||
// the invoice's revenue attribution survives an export/import round-trip.
|
||||
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||
var invoiceItemsCsv = GenerateInvoiceItemsCsv(invoices, accountNumberById);
|
||||
var invoiceItemsEntry = archive.CreateEntry($"invoice_items_{timestamp}.csv");
|
||||
using (var entryStream = invoiceItemsEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(invoiceItemsCsv);
|
||||
}
|
||||
|
||||
// 13. Expenses
|
||||
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
|
||||
var expensesCsv = GenerateExpensesCsv(expenses);
|
||||
@@ -2074,7 +2217,7 @@ public class ToolsController : Controller
|
||||
}
|
||||
|
||||
// 14. Payments
|
||||
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice);
|
||||
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice, p => p.DepositAccount);
|
||||
var paymentsCsv = GeneratePaymentsCsv(payments);
|
||||
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
|
||||
using (var entryStream = paymentsEntry.Open())
|
||||
@@ -2083,6 +2226,46 @@ public class ToolsController : Controller
|
||||
await writer.WriteAsync(paymentsCsv);
|
||||
}
|
||||
|
||||
// 15. Bills + bill line items (account/job by number, AP account, vendor by name)
|
||||
var jobNumberById = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)).ToDictionary(j => j.Id, j => j.JobNumber);
|
||||
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.Vendor, b => b.APAccount, b => b.LineItems);
|
||||
var billsEntry = archive.CreateEntry($"bills_{timestamp}.csv");
|
||||
using (var entryStream = billsEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(GenerateBillsCsv(bills));
|
||||
}
|
||||
var billLineItemsEntry = archive.CreateEntry($"bill_line_items_{timestamp}.csv");
|
||||
using (var entryStream = billLineItemsEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(GenerateBillLineItemsCsv(bills, accountNumberById, jobNumberById));
|
||||
}
|
||||
|
||||
// 16. Deposits (customer by name, bank account + applied invoice by number)
|
||||
var deposits = await _unitOfWork.Deposits.FindAsync(d => d.CompanyId == companyId.Value, false, d => d.Customer, d => d.AppliedToInvoice);
|
||||
var depositsEntry = archive.CreateEntry($"deposits_{timestamp}.csv");
|
||||
using (var entryStream = depositsEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(GenerateDepositsCsv(deposits, accountNumberById));
|
||||
}
|
||||
|
||||
// 17. Journal entries + lines (account by number, debit/credit)
|
||||
var journalEntries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Lines);
|
||||
var journalEntriesEntry = archive.CreateEntry($"journal_entries_{timestamp}.csv");
|
||||
using (var entryStream = journalEntriesEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(GenerateJournalEntriesCsv(journalEntries));
|
||||
}
|
||||
var journalEntryLinesEntry = archive.CreateEntry($"journal_entry_lines_{timestamp}.csv");
|
||||
using (var entryStream = journalEntryLinesEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(GenerateJournalEntryLinesCsv(journalEntries, accountNumberById));
|
||||
}
|
||||
|
||||
// 15. Purchase Orders
|
||||
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor);
|
||||
var purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders);
|
||||
@@ -2504,7 +2687,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
|
||||
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice, p => p.DepositAccount);
|
||||
var csv = GeneratePaymentsCsv(payments);
|
||||
var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
@@ -2519,6 +2702,164 @@ public class ToolsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports all invoice line items for the current company as a CSV, keyed by parent invoice number
|
||||
/// and carrying each line's revenue account number. Complements <see cref="ExportInvoicesCsv"/>
|
||||
/// (which is header-only) so invoice detail and revenue attribution round-trip on re-import.
|
||||
/// </summary>
|
||||
// GET: Tools/ExportInvoiceItemsCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportInvoiceItemsCsv()
|
||||
{
|
||||
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.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.InvoiceItems);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||
var csv = GenerateInvoiceItemsCsv(invoices, accountNumberById);
|
||||
var fileName = $"invoice_items_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
await LogExportAsync("InvoiceItems", $"CSV export ({invoices.Sum(i => i.InvoiceItems.Count)} line items)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting invoice items to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting invoice items.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exports vendor bill headers (vendor by name, AP account by number) as CSV.</summary>
|
||||
// GET: Tools/ExportBillsCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportBillsCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||
|
||||
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.Vendor, b => b.APAccount);
|
||||
var csv = GenerateBillsCsv(bills);
|
||||
await LogExportAsync("Bills", $"CSV export ({bills.Count()} records)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"bills_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting bills to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting bills.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exports vendor bill line items (account/job by number) as CSV.</summary>
|
||||
// GET: Tools/ExportBillLineItemsCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportBillLineItemsCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||
|
||||
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.LineItems);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value);
|
||||
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||
var jobNumberById = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)).ToDictionary(j => j.Id, j => j.JobNumber);
|
||||
var csv = GenerateBillLineItemsCsv(bills, accountNumberById, jobNumberById);
|
||||
await LogExportAsync("BillLineItems", $"CSV export ({bills.Sum(b => b.LineItems.Count)} line items)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"bill_line_items_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting bill line items to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting bill line items.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exports customer deposits (customer by name, bank account + applied invoice by number) as CSV.</summary>
|
||||
// GET: Tools/ExportDepositsCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportDepositsCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||
|
||||
var deposits = await _unitOfWork.Deposits.FindAsync(d => d.CompanyId == companyId.Value, false, d => d.Customer, d => d.AppliedToInvoice);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||
var csv = GenerateDepositsCsv(deposits, accountNumberById);
|
||||
await LogExportAsync("Deposits", $"CSV export ({deposits.Count()} records)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"deposits_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting deposits to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting deposits.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exports journal entry headers as CSV. Lines export separately.</summary>
|
||||
// GET: Tools/ExportJournalEntriesCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportJournalEntriesCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||
|
||||
var entries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value);
|
||||
var csv = GenerateJournalEntriesCsv(entries);
|
||||
await LogExportAsync("JournalEntries", $"CSV export ({entries.Count()} records)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"journal_entries_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting journal entries to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting journal entries.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exports journal entry lines (account by number, debit/credit) as CSV.</summary>
|
||||
// GET: Tools/ExportJournalEntryLinesCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportJournalEntryLinesCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||
|
||||
var entries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Lines);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||
var csv = GenerateJournalEntryLinesCsv(entries, accountNumberById);
|
||||
await LogExportAsync("JournalEntryLines", $"CSV export ({entries.Sum(e => e.Lines.Count)} lines)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"journal_entry_lines_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting journal entry lines to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting journal entry lines.";
|
||||
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.
|
||||
@@ -3973,6 +4314,35 @@ public class ToolsController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CSV of invoice line items — one row per item across all the given invoices. The parent
|
||||
/// invoice number and the line's revenue account number (resolved from <paramref name="accountNumberById"/>)
|
||||
/// are written so revenue attribution survives an export/import round-trip. Rows are emitted in
|
||||
/// DisplayOrder within each invoice.
|
||||
/// </summary>
|
||||
private string GenerateInvoiceItemsCsv(IEnumerable<Core.Entities.Invoice> invoices, IReadOnlyDictionary<int, string> accountNumberById)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("InvoiceNumber,Description,Quantity,UnitPrice,TotalPrice,ColorName,RevenueAccountNumber,DisplayOrder,Notes");
|
||||
|
||||
foreach (var invoice in invoices)
|
||||
{
|
||||
foreach (var item in invoice.InvoiceItems.OrderBy(it => it.DisplayOrder))
|
||||
{
|
||||
var revenueAccountNumber = item.RevenueAccountId.HasValue
|
||||
&& accountNumberById.TryGetValue(item.RevenueAccountId.Value, out var num)
|
||||
? num : "";
|
||||
|
||||
sb.AppendLine($"{EscapeCsv(invoice.InvoiceNumber)},{EscapeCsv(item.Description)}," +
|
||||
$"{item.Quantity},{item.UnitPrice},{item.TotalPrice}," +
|
||||
$"{EscapeCsv(item.ColorName)},{EscapeCsv(revenueAccountNumber)}," +
|
||||
$"{item.DisplayOrder},{EscapeCsv(item.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
|
||||
@@ -3981,13 +4351,111 @@ public class ToolsController : Controller
|
||||
private string GeneratePaymentsCsv(IEnumerable<Core.Entities.Payment> payments)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,Reference,Notes");
|
||||
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,DepositAccountNumber,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)}");
|
||||
$"{payment.PaymentMethod},{EscapeCsv(payment.DepositAccount?.AccountNumber)}," +
|
||||
$"{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the vendor bill header CSV — vendor by name, AP account by number.</summary>
|
||||
private string GenerateBillsCsv(IEnumerable<Core.Entities.Bill> bills)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("BillNumber,VendorInvoiceNumber,VendorName,APAccountNumber,BillDate,DueDate,Status,Terms,Memo,SubTotal,TaxPercent,TaxAmount,Total,AmountPaid");
|
||||
|
||||
foreach (var bill in bills)
|
||||
{
|
||||
sb.AppendLine($"{EscapeCsv(bill.BillNumber)},{EscapeCsv(bill.VendorInvoiceNumber)}," +
|
||||
$"{EscapeCsv(bill.Vendor?.CompanyName)},{EscapeCsv(bill.APAccount?.AccountNumber)}," +
|
||||
$"{bill.BillDate:yyyy-MM-dd},{bill.DueDate?.ToString("yyyy-MM-dd")},{bill.Status}," +
|
||||
$"{EscapeCsv(bill.Terms)},{EscapeCsv(bill.Memo)}," +
|
||||
$"{bill.SubTotal},{bill.TaxPercent},{bill.TaxAmount},{bill.Total},{bill.AmountPaid}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the bill line-item CSV — one row per line, account/job resolved by number.</summary>
|
||||
private string GenerateBillLineItemsCsv(IEnumerable<Core.Entities.Bill> bills,
|
||||
IReadOnlyDictionary<int, string> accountNumberById, IReadOnlyDictionary<int, string> jobNumberById)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("BillNumber,AccountNumber,JobNumber,Description,Quantity,UnitPrice,Amount,DisplayOrder");
|
||||
|
||||
foreach (var bill in bills)
|
||||
{
|
||||
foreach (var line in bill.LineItems.OrderBy(li => li.DisplayOrder))
|
||||
{
|
||||
var accountNumber = line.AccountId.HasValue && accountNumberById.TryGetValue(line.AccountId.Value, out var an) ? an : "";
|
||||
var jobNumber = line.JobId.HasValue && jobNumberById.TryGetValue(line.JobId.Value, out var jn) ? jn : "";
|
||||
sb.AppendLine($"{EscapeCsv(bill.BillNumber)},{EscapeCsv(accountNumber)},{EscapeCsv(jobNumber)}," +
|
||||
$"{EscapeCsv(line.Description)},{line.Quantity},{line.UnitPrice},{line.Amount},{line.DisplayOrder}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the customer deposit CSV — customer by name, bank account + applied invoice resolved.</summary>
|
||||
private string GenerateDepositsCsv(IEnumerable<Core.Entities.Deposit> deposits, IReadOnlyDictionary<int, string> accountNumberById)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("ReceiptNumber,CustomerName,Amount,PaymentMethod,ReceivedDate,DepositAccountNumber,AppliedToInvoiceNumber,AppliedDate,Reference,Notes");
|
||||
|
||||
foreach (var deposit in deposits)
|
||||
{
|
||||
var customerName = deposit.Customer != null
|
||||
? (!string.IsNullOrWhiteSpace(deposit.Customer.CompanyName)
|
||||
? deposit.Customer.CompanyName
|
||||
: $"{deposit.Customer.ContactFirstName} {deposit.Customer.ContactLastName}".Trim())
|
||||
: "";
|
||||
var depositAccountNumber = deposit.DepositAccountId.HasValue && accountNumberById.TryGetValue(deposit.DepositAccountId.Value, out var an) ? an : "";
|
||||
|
||||
sb.AppendLine($"{EscapeCsv(deposit.ReceiptNumber)},{EscapeCsv(customerName)},{deposit.Amount}," +
|
||||
$"{deposit.PaymentMethod},{deposit.ReceivedDate:yyyy-MM-dd},{EscapeCsv(depositAccountNumber)}," +
|
||||
$"{EscapeCsv(deposit.AppliedToInvoice?.InvoiceNumber)},{deposit.AppliedDate?.ToString("yyyy-MM-dd")}," +
|
||||
$"{EscapeCsv(deposit.Reference)},{EscapeCsv(deposit.Notes)}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the journal entry header CSV. Lines are exported separately.</summary>
|
||||
private string GenerateJournalEntriesCsv(IEnumerable<Core.Entities.JournalEntry> entries)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("EntryNumber,EntryDate,Reference,Description,Status");
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
sb.AppendLine($"{EscapeCsv(entry.EntryNumber)},{entry.EntryDate:yyyy-MM-dd}," +
|
||||
$"{EscapeCsv(entry.Reference)},{EscapeCsv(entry.Description)},{entry.Status}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the journal entry line CSV — one row per debit/credit line, account by number.</summary>
|
||||
private string GenerateJournalEntryLinesCsv(IEnumerable<Core.Entities.JournalEntry> entries, IReadOnlyDictionary<int, string> accountNumberById)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("EntryNumber,AccountNumber,DebitAmount,CreditAmount,Description,LineOrder");
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
foreach (var line in entry.Lines.OrderBy(l => l.LineOrder))
|
||||
{
|
||||
var accountNumber = accountNumberById.TryGetValue(line.AccountId, out var an) ? an : "";
|
||||
sb.AppendLine($"{EscapeCsv(entry.EntryNumber)},{EscapeCsv(accountNumber)}," +
|
||||
$"{line.DebitAmount},{line.CreditAmount},{EscapeCsv(line.Description)},{line.LineOrder}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
|
||||
@@ -88,7 +88,17 @@
|
||||
tips: ['Download the CSV template to see the expected columns',
|
||||
'Customers must exist before importing — matched by CustomerEmail then Customer name',
|
||||
'Existing invoices matched by InvoiceNumber are updated; new ones are created',
|
||||
'Line items are not part of the CSV — this imports invoice headers and totals only'] },
|
||||
'Line items import separately — run the Invoice Line Items import after this one'] },
|
||||
|
||||
{ key: 'csv-invoiceitems',
|
||||
label: 'Invoice Line Items', icon: 'bi-list-ul', color: '#0e7490',
|
||||
desc: 'Invoice line items with revenue account attribution',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportInvoiceItems', accept: '.csv',
|
||||
template: '/Tools/DownloadInvoiceItemTemplate',
|
||||
tips: ['Import invoices first — each line is matched to its parent by InvoiceNumber',
|
||||
'RevenueAccountNumber is optional and matched against your Chart of Accounts',
|
||||
'Re-running is safe — duplicate lines (same description + total + order) are skipped'] },
|
||||
|
||||
{ key: 'csv-appointments',
|
||||
label: 'Appointments', icon: 'bi-calendar-check', color: '#2563eb',
|
||||
@@ -157,6 +167,53 @@
|
||||
'Valid PaymentMethod values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment',
|
||||
'Invoice AmountPaid and status are updated automatically after each payment'] },
|
||||
|
||||
{ key: 'csv-bills',
|
||||
label: 'Bills (AP)', icon: 'bi-file-earmark-ruled', color: '#b45309',
|
||||
desc: 'Vendor bill headers — vendor by name, AP account by number',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportBills', accept: '.csv',
|
||||
tips: ['Import Chart of Accounts and Vendors first',
|
||||
'VendorName matches a vendor; APAccountNumber matches your Chart of Accounts',
|
||||
'Existing bills matched by BillNumber are skipped',
|
||||
'Import bill line items separately after this'] },
|
||||
|
||||
{ key: 'csv-billlineitems',
|
||||
label: 'Bill Line Items', icon: 'bi-list-ul', color: '#b45309',
|
||||
desc: 'Bill line items with expense account attribution',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportBillLineItems', accept: '.csv',
|
||||
tips: ['Import bills first — lines are matched to their parent by BillNumber',
|
||||
'AccountNumber (expense/asset) and JobNumber are optional',
|
||||
'Re-running is safe — duplicate lines are skipped'] },
|
||||
|
||||
{ key: 'csv-deposits',
|
||||
label: 'Customer Deposits', icon: 'bi-piggy-bank', color: '#059669',
|
||||
desc: 'Deposits with customer, bank account, and applied invoice',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportDeposits', accept: '.csv',
|
||||
tips: ['Import Customers and Chart of Accounts first',
|
||||
'CustomerName matches a customer; DepositAccountNumber matches your Chart of Accounts',
|
||||
'AppliedToInvoiceNumber is optional — links the deposit to an invoice',
|
||||
'Existing deposits matched by ReceiptNumber are skipped'] },
|
||||
|
||||
{ key: 'csv-journalentries',
|
||||
label: 'Journal Entries', icon: 'bi-journal-bookmark', color: '#374151',
|
||||
desc: 'Journal entry headers — import lines separately after',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportJournalEntries', accept: '.csv',
|
||||
tips: ['Existing entries matched by EntryNumber are skipped',
|
||||
'Valid Status values: Draft, Posted, Reversed',
|
||||
'Import the entry lines separately after this'] },
|
||||
|
||||
{ key: 'csv-journalentrylines',
|
||||
label: 'Journal Entry Lines', icon: 'bi-list-columns', color: '#374151',
|
||||
desc: 'Debit/credit lines with account attribution',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportJournalEntryLines', accept: '.csv',
|
||||
tips: ['Import journal entries and Chart of Accounts first',
|
||||
'Lines are matched to their entry by EntryNumber; AccountNumber is required',
|
||||
'Each line is a debit or a credit — entries should balance'] },
|
||||
|
||||
{ key: 'csv-purchaseorders',
|
||||
label: 'Purchase Orders', icon: 'bi-cart', color: '#6b7280',
|
||||
desc: 'Purchase order headers with vendor, status, and totals',
|
||||
@@ -204,11 +261,41 @@
|
||||
desc: 'Invoice headers, amounts, status, and customer info',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInvoicesCsv' },
|
||||
|
||||
{ key: 'exp-invoiceitems',
|
||||
label: 'Invoice Line Items', icon: 'bi-list-ul', color: '#0e7490',
|
||||
desc: 'Line items with revenue account, keyed by invoice number',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInvoiceItemsCsv' },
|
||||
|
||||
{ key: 'exp-payments',
|
||||
label: 'Payments', icon: 'bi-cash-coin', color: '#059669',
|
||||
desc: 'Invoice payment records with method and reference',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPaymentsCsv' },
|
||||
|
||||
{ key: 'exp-bills',
|
||||
label: 'Bills (AP)', icon: 'bi-file-earmark-ruled', color: '#b45309',
|
||||
desc: 'Vendor bill headers with vendor and AP account',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportBillsCsv' },
|
||||
|
||||
{ key: 'exp-billlineitems',
|
||||
label: 'Bill Line Items', icon: 'bi-list-ul', color: '#b45309',
|
||||
desc: 'Bill line items with expense account, keyed by bill number',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportBillLineItemsCsv' },
|
||||
|
||||
{ key: 'exp-deposits',
|
||||
label: 'Customer Deposits', icon: 'bi-piggy-bank', color: '#059669',
|
||||
desc: 'Deposits with customer, bank account, and applied invoice',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportDepositsCsv' },
|
||||
|
||||
{ key: 'exp-journalentries',
|
||||
label: 'Journal Entries', icon: 'bi-journal-bookmark', color: '#374151',
|
||||
desc: 'Journal entry headers with reference and status',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportJournalEntriesCsv' },
|
||||
|
||||
{ key: 'exp-journalentrylines',
|
||||
label: 'Journal Entry Lines', icon: 'bi-list-columns', color: '#374151',
|
||||
desc: 'Debit/credit lines with account, keyed by entry number',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportJournalEntryLinesCsv' },
|
||||
|
||||
{ key: 'exp-appointments',
|
||||
label: 'Appointments', icon: 'bi-calendar-check', color: '#d97706',
|
||||
desc: 'Customer, type, status, and scheduling details',
|
||||
|
||||
Reference in New Issue
Block a user