feat: add purchase invoice batch print page and related components
- Implemented PurchaseInvoiceBatchPrintPage for printing purchase invoices. - Created InvoicePrintHeader and InvoicePrintFooter components for invoice layout. - Added invoice layout presets for different styles (Classic, Compact, Modern). - Developed PurchaseInvoicePaymentDialog for handling payments against invoices. - Introduced API mocks for company profile and invoice template configuration. - Added validation functions for company profile and purchase invoices. - Defined types for company profile, invoice template configuration, and purchase invoices.
This commit is contained in:
@@ -1,120 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Vendor payments against a confirmed GRN. Multiple installments are allowed until
|
||||
/// <see cref="Grn.BalanceAmount"/> reaches zero. Each payment posts its own real GL
|
||||
/// journal entry (Debit GRN Clearing / Credit the selected bank-or-cash account) before
|
||||
/// being recorded, using the same call-GL-before-commit pattern as <see cref="GrnService.ConfirmAsync"/>
|
||||
/// so a rejected/unreachable GL post rolls back the whole payment atomically.
|
||||
/// </summary>
|
||||
public sealed class GrnPaymentService : IGrnPaymentService
|
||||
{
|
||||
private readonly IRepository<Grn> _grns;
|
||||
private readonly IRepository<GrnPayment> _payments;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly string _glClearingAccountCode;
|
||||
|
||||
public GrnPaymentService(
|
||||
IRepository<Grn> grns, IRepository<GrnPayment> payments, IGeneralLedgerService gl,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
_payments = payments;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query().FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
if (grn.Status == GrnStatus.Draft)
|
||||
throw new DomainException(ErrorCodes.GrnNotPayable, $"GRN {grnId} must be confirmed before it can be paid.", 409);
|
||||
if (request.Amount > grn.BalanceAmount)
|
||||
throw new DomainException(ErrorCodes.GrnPaymentExceedsBalance,
|
||||
$"Payment amount {request.Amount} exceeds the remaining balance {grn.BalanceAmount}.", 400);
|
||||
|
||||
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
|
||||
?? throw new DomainException(ErrorCodes.GrnBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// Same atomicity approach as GrnService.ConfirmAsync: post to GL first, inside
|
||||
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||
// the whole payment back with no partial local state.
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "GRN_PAYMENT",
|
||||
Reference = grn.DocNo,
|
||||
Narration = $"Payment against GRN {grn.DocNo}",
|
||||
Lines =
|
||||
[
|
||||
new GlJournalEntryLineRequest(_glClearingAccountCode, request.Amount, 0m, $"Payment against GRN {grn.DocNo}"),
|
||||
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against GRN {grn.DocNo}")
|
||||
]
|
||||
}, token);
|
||||
|
||||
var entity = new GrnPayment
|
||||
{
|
||||
GrnId = grn.GrnId,
|
||||
Amount = request.Amount,
|
||||
PaymentDate = now,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
GlJournalNo = posted.JournalNo,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now
|
||||
};
|
||||
await _payments.AddAsync(entity, token);
|
||||
|
||||
grn.PaidAmount += request.Amount;
|
||||
grn.BalanceAmount -= request.Amount;
|
||||
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return Map(payment);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _grns.Query().AnyAsync(g => g.GrnId == grnId, ct))
|
||||
throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
return await _payments.Query().AsNoTracking()
|
||||
.Where(p => p.GrnId == grnId)
|
||||
.OrderByDescending(p => p.GrnPaymentId)
|
||||
.Select(p => new GrnPaymentDto(
|
||||
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private static GrnPaymentDto Map(GrnPayment p) => new(
|
||||
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
|
||||
}
|
||||
@@ -92,7 +92,8 @@ public sealed class GrnService : IGrnService
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(g => new GrnSummaryDto(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.PaidAmount, g.BalanceAmount))
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
|
||||
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -102,6 +103,7 @@ public sealed class GrnService : IGrnService
|
||||
{
|
||||
var grn = await _grns.Query().AsNoTracking()
|
||||
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
|
||||
.Include(g => g.PurchaseInvoice)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
||||
return grn is null ? null : Map(grn);
|
||||
}
|
||||
@@ -302,15 +304,13 @@ public sealed class GrnService : IGrnService
|
||||
grn.PostedAt = now;
|
||||
grn.GlJournalNo = posted.JournalNo;
|
||||
grn.GlPostedAt = now;
|
||||
grn.PaidAmount = 0m;
|
||||
grn.BalanceAmount = totalPayable;
|
||||
|
||||
await UpdatePoStatusAsync(grn.PoId, token);
|
||||
return 0;
|
||||
}, ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
|
||||
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty,
|
||||
createdLayers.Select(ToCreatedLayer).ToList(),
|
||||
ledgerRefs.Select(l => l.LedgerId).ToList(),
|
||||
await GetPoStatusAsync(grn.PoId, ct));
|
||||
@@ -437,7 +437,7 @@ public sealed class GrnService : IGrnService
|
||||
.Select(l => l.LedgerId).ToListAsync(ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
|
||||
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty,
|
||||
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
|
||||
}
|
||||
|
||||
@@ -446,7 +446,7 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
private static GrnDto Map(Grn g) => new(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
|
||||
g.GlJournalNo, g.PaidAmount, g.BalanceAmount,
|
||||
g.GlJournalNo, g.PurchaseInvoiceId, g.PurchaseInvoice?.DocNo,
|
||||
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
|
||||
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
using ERPCore.Dtos.Grn;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Vendor payments against a confirmed GRN's balance (installments allowed).</summary>
|
||||
public interface IGrnPaymentService
|
||||
{
|
||||
Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ERPCore.Dtos.Procurement;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Vendor payments against a Posted purchase invoice's balance (installments allowed).</summary>
|
||||
public interface IPurchaseInvoicePaymentService
|
||||
{
|
||||
Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface IPurchaseInvoiceService
|
||||
{
|
||||
Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default);
|
||||
Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default);
|
||||
Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default);
|
||||
Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
|
||||
Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Vendor payments against a Posted purchase invoice. Multiple installments are allowed
|
||||
/// until <see cref="PurchaseInvoice.BalanceAmount"/> reaches zero. Each payment posts its
|
||||
/// own real GL journal entry (Debit Accounts Payable / Credit the selected bank-or-cash
|
||||
/// account) — the mirror image of <see cref="SalesInvoicePaymentService"/> (that one
|
||||
/// credits Accounts Receivable when the customer pays; this one debits Accounts Payable
|
||||
/// when the vendor is paid) — using the same call-GL-before-commit pattern so a
|
||||
/// rejected/unreachable GL post rolls back the whole payment atomically.
|
||||
/// </summary>
|
||||
public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentService
|
||||
{
|
||||
private readonly IRepository<PurchaseInvoice> _invoices;
|
||||
private readonly IRepository<PurchaseInvoicePayment> _payments;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly string _glAccountsPayableCode;
|
||||
|
||||
public PurchaseInvoicePaymentService(
|
||||
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.Query().FirstOrDefaultAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
|
||||
|
||||
if (invoice.Status != PurchaseInvoiceStatus.Posted)
|
||||
throw new DomainException(ErrorCodes.PurchaseInvoiceNotPayable, $"Purchase invoice {purchaseInvoiceId} must be posted before it can be paid.", 409);
|
||||
if (request.Amount > invoice.BalanceAmount)
|
||||
throw new DomainException(ErrorCodes.PurchaseInvoicePaymentExceedsBalance,
|
||||
$"Payment amount {request.Amount} exceeds the remaining balance {invoice.BalanceAmount}.", 400);
|
||||
|
||||
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
|
||||
?? throw new DomainException(ErrorCodes.PurchaseInvoiceBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "PURCHASE_INVOICE_PAYMENT",
|
||||
Reference = invoice.DocNo,
|
||||
Narration = $"Payment against invoice {invoice.DocNo}",
|
||||
Lines =
|
||||
[
|
||||
new GlJournalEntryLineRequest(_glAccountsPayableCode, request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
|
||||
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}")
|
||||
]
|
||||
}, token);
|
||||
|
||||
var entity = new PurchaseInvoicePayment
|
||||
{
|
||||
PurchaseInvoiceId = invoice.PurchaseInvoiceId,
|
||||
Amount = request.Amount,
|
||||
PaymentDate = now,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
GlJournalNo = posted.JournalNo,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now
|
||||
};
|
||||
await _payments.AddAsync(entity, token);
|
||||
|
||||
invoice.PaidAmount += request.Amount;
|
||||
invoice.BalanceAmount -= request.Amount;
|
||||
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return Map(payment);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct))
|
||||
throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
|
||||
|
||||
return await _payments.Query().AsNoTracking()
|
||||
.Where(p => p.PurchaseInvoiceId == purchaseInvoiceId)
|
||||
.OrderByDescending(p => p.PurchaseInvoicePaymentId)
|
||||
.Select(p => new PurchaseInvoicePaymentDto(
|
||||
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private static PurchaseInvoicePaymentDto Map(PurchaseInvoicePayment p) => new(
|
||||
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Purchase invoice service. A purchase invoice consolidates one or more Confirmed
|
||||
/// GRNs for the same vendor into a single vendor-facing document — each linked GRN
|
||||
/// becomes one <see cref="PurchaseInvoiceLine"/>, a straight sum of that GRN's line
|
||||
/// totals (no re-itemization). Draft invoices are fully editable (GRNs can be
|
||||
/// re-selected); Post reclassifies the GRN clearing balance to Accounts Payable via a
|
||||
/// real GL journal entry (mirrors <see cref="SalesInvoiceService"/>'s AR recognition);
|
||||
/// vendor payments are then recorded against this aggregate (see
|
||||
/// <see cref="PurchaseInvoicePaymentService"/>) rather than per-GRN.
|
||||
/// </summary>
|
||||
public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
{
|
||||
private readonly IRepository<PurchaseInvoice> _invoices;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Grn> _grns;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glClearingAccountCode;
|
||||
private readonly string _glAccountsPayableCode;
|
||||
|
||||
public PurchaseInvoiceService(
|
||||
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_vendors = vendors;
|
||||
_grns = grns;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glClearingAccountCode = configuration["PurchaseInvoice:GlClearingAccountCode"] ?? string.Empty;
|
||||
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<PurchaseInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(x => EF.Functions.ILike(x.DocNo, $"%{term}%")
|
||||
|| EF.Functions.ILike(x.VendorInvoiceNo, $"%{term}%")
|
||||
|| EF.Functions.ILike(x.VendorSnapshotName, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(x => x.Status == status);
|
||||
if (vendorId is not null) q = q.Where(x => x.VendorId == vendorId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.PurchaseInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
|
||||
return PagedResponse<PurchaseInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.Query().AsNoTracking()
|
||||
.Include(x => x.Lines).ThenInclude(l => l.Grn)
|
||||
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct);
|
||||
return invoice is null ? null : new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
|
||||
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
|
||||
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: null, ct);
|
||||
|
||||
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: null, ct);
|
||||
|
||||
var invoice = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var entity = new PurchaseInvoice
|
||||
{
|
||||
DocNo = await _numbers.NextAsync(DocumentTypes.PurchaseInvoice, token),
|
||||
VendorInvoiceNo = vendorInvoiceNo,
|
||||
VendorInvoiceDate = request.VendorInvoiceDate,
|
||||
VendorId = request.VendorId,
|
||||
VendorSnapshotName = vendor.Name,
|
||||
VendorSnapshotTaxNo = vendor.TaxReg,
|
||||
Status = PurchaseInvoiceStatus.Draft,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = grns.Select(BuildLine).ToList()
|
||||
};
|
||||
Recalculate(entity);
|
||||
await _invoices.AddAsync(entity, token);
|
||||
|
||||
foreach (var grn in grns) grn.PurchaseInvoice = entity;
|
||||
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.Query()
|
||||
.Include(x => x.Lines).ThenInclude(l => l.Grn)
|
||||
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
|
||||
|
||||
if (invoice.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase invoice was modified by another request.", 412);
|
||||
if (invoice.Status != PurchaseInvoiceStatus.Draft)
|
||||
throw new DomainException(ErrorCodes.PurchaseInvoiceNotEditable, $"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be edited.", 409);
|
||||
|
||||
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
|
||||
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
|
||||
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
|
||||
|
||||
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
|
||||
|
||||
// Free GRNs that were linked to this invoice but are no longer selected.
|
||||
var newGrnIds = grns.Select(g => g.GrnId).ToHashSet();
|
||||
foreach (var line in invoice.Lines)
|
||||
if (line.Grn is not null && !newGrnIds.Contains(line.Grn.GrnId)) line.Grn.PurchaseInvoiceId = null;
|
||||
|
||||
invoice.VendorId = request.VendorId;
|
||||
invoice.VendorInvoiceNo = vendorInvoiceNo;
|
||||
invoice.VendorInvoiceDate = request.VendorInvoiceDate;
|
||||
invoice.VendorSnapshotName = vendor.Name;
|
||||
invoice.VendorSnapshotTaxNo = vendor.TaxReg;
|
||||
invoice.Lines.Clear();
|
||||
foreach (var line in grns.Select(BuildLine)) invoice.Lines.Add(line);
|
||||
foreach (var grn in grns) grn.PurchaseInvoice = invoice;
|
||||
Recalculate(invoice);
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.Query()
|
||||
.Include(x => x.Lines).ThenInclude(l => l.Grn)
|
||||
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
|
||||
|
||||
if (invoice.Status != PurchaseInvoiceStatus.Draft)
|
||||
throw new ConflictException($"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be posted.");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
|
||||
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
|
||||
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync).
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "PURCHASE_INVOICE",
|
||||
Reference = invoice.DocNo,
|
||||
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
|
||||
Lines =
|
||||
[
|
||||
new GlJournalEntryLineRequest(_glClearingAccountCode, invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
|
||||
new GlJournalEntryLineRequest(_glAccountsPayableCode, 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
|
||||
]
|
||||
}, token);
|
||||
|
||||
invoice.Status = PurchaseInvoiceStatus.Posted;
|
||||
invoice.GlJournalNo = posted.JournalNo;
|
||||
invoice.GlPostedAt = now;
|
||||
invoice.UpdatedAt = now;
|
||||
return 0;
|
||||
}, ct);
|
||||
|
||||
return MapInvoice(invoice);
|
||||
}
|
||||
|
||||
public async Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _invoices.Query()
|
||||
.Include(x => x.Lines).ThenInclude(l => l.Grn)
|
||||
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
|
||||
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
|
||||
|
||||
if (invoice.Status != PurchaseInvoiceStatus.Draft)
|
||||
throw new ConflictException($"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be cancelled.");
|
||||
|
||||
var dto = MapInvoice(invoice);
|
||||
|
||||
// Cancelling frees the consolidated GRNs so they can be picked up by a later invoice —
|
||||
// the link rows are removed (the unique index on GrnId only tracks the active link),
|
||||
// while the Cancelled header row itself remains for audit history.
|
||||
foreach (var line in invoice.Lines)
|
||||
if (line.Grn is not null) line.Grn.PurchaseInvoiceId = null;
|
||||
invoice.Lines.Clear();
|
||||
invoice.Status = PurchaseInvoiceStatus.Cancelled;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return dto with { Status = PurchaseInvoiceStatus.Cancelled, UpdatedAt = invoice.UpdatedAt };
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default)
|
||||
{
|
||||
return await _grns.Query().AsNoTracking()
|
||||
.Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null)
|
||||
.OrderBy(g => g.PostedAt)
|
||||
.Select(g => new GrnEligibleForInvoiceDto(
|
||||
g.GrnId, g.DocNo, g.PostedAt ?? g.CreatedAt, g.WarehouseId, g.Lines.Count,
|
||||
g.Lines.Sum(l => l.ReceivedValue), g.Lines.Sum(l => l.VatAmount), g.Lines.Sum(l => l.LineTotal)))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private async Task EnsureVendorInvoiceNoIsUniqueAsync(int vendorId, string vendorInvoiceNo, int? excludingPurchaseInvoiceId, CancellationToken ct)
|
||||
{
|
||||
var duplicate = await _invoices.Query().AsNoTracking().AnyAsync(x =>
|
||||
x.VendorId == vendorId
|
||||
&& x.Status != PurchaseInvoiceStatus.Cancelled
|
||||
&& x.VendorInvoiceNo == vendorInvoiceNo
|
||||
&& (excludingPurchaseInvoiceId == null || x.PurchaseInvoiceId != excludingPurchaseInvoiceId), ct);
|
||||
if (duplicate)
|
||||
throw new DomainException(ErrorCodes.DuplicateVendorInvoiceNo,
|
||||
$"Vendor invoice number '{vendorInvoiceNo}' already exists for this vendor.", 409);
|
||||
}
|
||||
|
||||
private async Task<List<Grn>> LoadAndValidateGrnsAsync(int vendorId, List<int> grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct)
|
||||
{
|
||||
var distinctIds = grnIds.Distinct().ToList();
|
||||
var grns = await _grns.Query().Include(g => g.Lines)
|
||||
.Where(g => distinctIds.Contains(g.GrnId)).ToListAsync(ct);
|
||||
|
||||
if (grns.Count != distinctIds.Count)
|
||||
throw new DomainException(ErrorCodes.Validation, "One or more selected GRNs do not exist.", 422);
|
||||
|
||||
foreach (var grn in grns)
|
||||
{
|
||||
if (grn.Status != GrnStatus.Confirmed)
|
||||
throw new DomainException(ErrorCodes.GrnNotConfirmed, $"GRN {grn.DocNo} must be confirmed before it can be invoiced.", 409);
|
||||
if (grn.VendorId != vendorId)
|
||||
throw new DomainException(ErrorCodes.GrnVendorMismatch, $"GRN {grn.DocNo} belongs to a different vendor.", 409);
|
||||
if (grn.PurchaseInvoiceId is not null && grn.PurchaseInvoiceId != excludingPurchaseInvoiceId)
|
||||
throw new DomainException(ErrorCodes.GrnAlreadyInvoiced, $"GRN {grn.DocNo} is already linked to another purchase invoice.", 409);
|
||||
}
|
||||
|
||||
return grns;
|
||||
}
|
||||
|
||||
private static PurchaseInvoiceLine BuildLine(Grn grn) => new()
|
||||
{
|
||||
Grn = grn,
|
||||
GrnId = grn.GrnId,
|
||||
Subtotal = grn.Lines.Sum(l => l.ReceivedValue),
|
||||
VatTotal = grn.Lines.Sum(l => l.VatAmount),
|
||||
LineTotal = grn.Lines.Sum(l => l.LineTotal)
|
||||
};
|
||||
|
||||
private static void Recalculate(PurchaseInvoice invoice)
|
||||
{
|
||||
invoice.Subtotal = invoice.Lines.Sum(x => x.Subtotal);
|
||||
invoice.VatTotal = invoice.Lines.Sum(x => x.VatTotal);
|
||||
invoice.GrandTotal = invoice.Lines.Sum(x => x.LineTotal);
|
||||
invoice.PaidAmount = 0m;
|
||||
invoice.BalanceAmount = invoice.GrandTotal;
|
||||
}
|
||||
|
||||
private static PurchaseInvoiceTotalsDto MapTotals(PurchaseInvoice x) => new(
|
||||
x.Subtotal, x.VatTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount);
|
||||
|
||||
private static PurchaseInvoiceSummaryDto MapSummary(PurchaseInvoice x) => new(
|
||||
x.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId,
|
||||
x.VendorSnapshotName, x.Status, x.Lines.Count, MapTotals(x), x.CreatedAt, x.GlJournalNo);
|
||||
|
||||
private static PurchaseInvoiceDto MapInvoice(PurchaseInvoice x) => new(
|
||||
x.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId,
|
||||
x.VendorSnapshotName, x.VendorSnapshotTaxNo, x.Status, x.Lines.Count, x.CreatedBy, x.CreatedAt, x.UpdatedAt,
|
||||
x.GlJournalNo, x.GlPostedAt, MapTotals(x),
|
||||
x.Lines.OrderBy(l => l.PurchaseInvoiceLineId).Select(l => new PurchaseInvoiceLineDto(
|
||||
l.PurchaseInvoiceLineId, l.GrnId, l.Grn?.DocNo ?? string.Empty, l.Grn?.PostedAt ?? default,
|
||||
l.Subtotal, l.VatTotal, l.LineTotal)).ToList());
|
||||
}
|
||||
@@ -16,8 +16,8 @@ namespace ERPCore.Services;
|
||||
/// until <see cref="SalesInvoice.BalanceAmount"/> reaches zero. Each payment posts its
|
||||
/// own real GL journal entry (Debit the selected bank-or-cash account / Credit Accounts
|
||||
/// Receivable) before being recorded, using the same call-GL-before-commit pattern as
|
||||
/// <see cref="GrnPaymentService"/> — the mirror image of it (that one credits a payable
|
||||
/// clearing account when vendor-paid; this one credits the receivable asset when
|
||||
/// <see cref="PurchaseInvoicePaymentService"/> — the mirror image of it (that one debits a
|
||||
/// payable account when the vendor is paid; this one credits the receivable asset when
|
||||
/// customer-paid) — so a rejected/unreachable GL post rolls back the whole payment atomically.
|
||||
/// </summary>
|
||||
public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
|
||||
Reference in New Issue
Block a user