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:
@@ -11,12 +11,10 @@ namespace ERPCore.Controllers;
|
||||
public sealed class GrnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnService _grns;
|
||||
private readonly IGrnPaymentService _payments;
|
||||
|
||||
public GrnsController(IGrnService grns, IGrnPaymentService payments)
|
||||
public GrnsController(IGrnService grns)
|
||||
{
|
||||
_grns = grns;
|
||||
_payments = payments;
|
||||
}
|
||||
|
||||
/// <summary>List GRNs, newest first.</summary>
|
||||
@@ -64,23 +62,4 @@ public sealed class GrnsController : ApiControllerBase
|
||||
public async Task<ActionResult<ReleaseLineResultDto>> Release(
|
||||
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
|
||||
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
|
||||
|
||||
/// <summary>Pay the vendor against this GRN's balance, in full or in installments; posts a real GL journal entry.</summary>
|
||||
[HttpPost("{grnId:int}/payments")]
|
||||
[ProducesResponseType(typeof(GrnPaymentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<GrnPaymentDto>> Pay(int grnId, [FromBody] CreateGrnPaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _payments.PayAsync(grnId, request, ct);
|
||||
return Created($"/api/v1/grns/{grnId}/payments/{dto.GrnPaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Payment history for this GRN, newest first.</summary>
|
||||
[HttpGet("{grnId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<GrnPaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<GrnPaymentDto>>> ListPayments(int grnId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(grnId, ct));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.</summary>
|
||||
[Route("api/v1/purchase-invoices")]
|
||||
public sealed class PurchaseInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseInvoiceService _invoices;
|
||||
private readonly IPurchaseInvoicePaymentService _payments;
|
||||
|
||||
public PurchaseInvoicesController(IPurchaseInvoiceService invoices, IPurchaseInvoicePaymentService payments)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<PurchaseInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PurchaseInvoiceSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] PurchaseInvoiceStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
|
||||
=> Ok(await _invoices.ListAsync(query, status, vendorId, ct));
|
||||
|
||||
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
|
||||
[HttpGet("eligible-grns")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<GrnEligibleForInvoiceDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<GrnEligibleForInvoiceDto>>> EligibleGrns([FromQuery] int vendorId, CancellationToken ct)
|
||||
=> Ok(await _invoices.ListEligibleGrnsAsync(vendorId, ct));
|
||||
|
||||
[HttpGet("{purchaseInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PurchaseInvoiceDto>> GetById(int purchaseInvoiceId, CancellationToken ct)
|
||||
{
|
||||
var result = await _invoices.GetAsync(purchaseInvoiceId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<PurchaseInvoiceDto>> Create([FromBody] CreatePurchaseInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _invoices.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/purchase-invoices/{result.Value.PurchaseInvoiceId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{purchaseInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<PurchaseInvoiceDto>> Update(int purchaseInvoiceId, [FromBody] UpdatePurchaseInvoiceRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _invoices.UpdateAsync(purchaseInvoiceId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{purchaseInvoiceId:int}/post")]
|
||||
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseInvoiceDto>> Post(int purchaseInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.PostAsync(purchaseInvoiceId, ct));
|
||||
|
||||
[HttpPost("{purchaseInvoiceId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseInvoiceDto>> Cancel(int purchaseInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.CancelAsync(purchaseInvoiceId, ct));
|
||||
|
||||
/// <summary>Pay the vendor against this invoice's balance, in full or in installments; posts a real GL journal entry.</summary>
|
||||
[HttpPost("{purchaseInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(PurchaseInvoicePaymentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseInvoicePaymentDto>> Pay(int purchaseInvoiceId, [FromBody] CreatePurchaseInvoicePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _payments.PayAsync(purchaseInvoiceId, request, ct);
|
||||
return Created($"/api/v1/purchase-invoices/{purchaseInvoiceId}/payments/{dto.PurchaseInvoicePaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Payment history for this invoice, newest first.</summary>
|
||||
[HttpGet("{purchaseInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PurchaseInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<PurchaseInvoicePaymentDto>>> ListPayments(int purchaseInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(purchaseInvoiceId, ct));
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public static class DocumentTypes
|
||||
public const string Rfq = "RFQ";
|
||||
public const string PurchaseOrder = "PO";
|
||||
public const string Grn = "GRN";
|
||||
public const string PurchaseInvoice = "PINV";
|
||||
public const string Transfer = "TRF";
|
||||
public const string Adjustment = "ADJ";
|
||||
public const string Count = "CNT";
|
||||
|
||||
@@ -34,14 +34,14 @@ public class Grn
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
/// <summary>Sum of vendor payments made against this GRN's total payable (<see cref="GrnLine.LineTotal"/>).</summary>
|
||||
public decimal PaidAmount { get; set; }
|
||||
/// <summary>Total payable minus <see cref="PaidAmount"/>; installments accrue against this until it reaches zero.</summary>
|
||||
public decimal BalanceAmount { get; set; }
|
||||
/// <summary>The Purchase Invoice this GRN has been consolidated into, if any (null until
|
||||
/// invoiced). Vendor settlement is tracked on the invoice, not per-GRN — see
|
||||
/// <see cref="PurchaseInvoice"/>.</summary>
|
||||
public int? PurchaseInvoiceId { get; set; }
|
||||
public PurchaseInvoice? PurchaseInvoice { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
|
||||
public ICollection<GrnPayment> Payments { get; set; } = new List<GrnPayment>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Vendor invoice consolidating one or more Confirmed GRNs for the same vendor
|
||||
/// (each linked GRN becomes one <see cref="PurchaseInvoiceLine"/>, not re-itemized).
|
||||
/// Draft is editable (GRNs can be re-selected); Posted reclassifies the GRN clearing
|
||||
/// balance to Accounts Payable (see PurchaseInvoiceService.PostAsync); vendor payments
|
||||
/// are then recorded against this aggregate rather than against the individual GRNs.
|
||||
/// </summary>
|
||||
public class PurchaseInvoice
|
||||
{
|
||||
public int PurchaseInvoiceId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public string VendorInvoiceNo { get; set; } = string.Empty;
|
||||
public DateTime VendorInvoiceDate { get; set; }
|
||||
|
||||
public int VendorId { get; set; }
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public string VendorSnapshotName { get; set; } = string.Empty;
|
||||
public string? VendorSnapshotTaxNo { get; set; }
|
||||
|
||||
public PurchaseInvoiceStatus Status { get; set; } = PurchaseInvoiceStatus.Draft;
|
||||
|
||||
public decimal Subtotal { get; set; }
|
||||
public decimal VatTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
public decimal PaidAmount { get; set; }
|
||||
public decimal BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>Journal number of the AP-recognition GL journal entry posted when this
|
||||
/// invoice is Posted (Debit GRN Clearing / Credit Accounts Payable) — see
|
||||
/// PurchaseInvoiceService.PostAsync.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<PurchaseInvoiceLine> Lines { get; set; } = new List<PurchaseInvoiceLine>();
|
||||
public ICollection<PurchaseInvoicePayment> Payments { get; set; } = new List<PurchaseInvoicePayment>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per consolidated GRN on a <see cref="PurchaseInvoice"/> — a straight
|
||||
/// consolidation of that GRN's line totals, not a re-itemization (docs: Purchase
|
||||
/// Invoice consolidates GRNs, it does not re-enter item lines).
|
||||
/// </summary>
|
||||
public class PurchaseInvoiceLine
|
||||
{
|
||||
public int PurchaseInvoiceLineId { get; set; }
|
||||
|
||||
public int PurchaseInvoiceId { get; set; }
|
||||
public PurchaseInvoice? PurchaseInvoice { get; set; }
|
||||
|
||||
public int GrnId { get; set; }
|
||||
public Grn? Grn { get; set; }
|
||||
|
||||
public decimal Subtotal { get; set; }
|
||||
public decimal VatTotal { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
}
|
||||
+8
-7
@@ -1,16 +1,17 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A vendor payment made against a confirmed GRN's balance (installments allowed —
|
||||
/// see GrnPaymentService.PayAsync). Posts its own real GL journal entry (Debit GRN
|
||||
/// Clearing / Credit the selected bank-or-cash account) before being recorded here.
|
||||
/// A vendor payment made against a Posted purchase invoice's balance (installments
|
||||
/// allowed — see PurchaseInvoicePaymentService.PayAsync). Posts its own real GL
|
||||
/// journal entry (Debit Accounts Payable / Credit the selected bank-or-cash account)
|
||||
/// before being recorded here.
|
||||
/// </summary>
|
||||
public class GrnPayment
|
||||
public class PurchaseInvoicePayment
|
||||
{
|
||||
public int GrnPaymentId { get; set; }
|
||||
public int PurchaseInvoicePaymentId { get; set; }
|
||||
|
||||
public int GrnId { get; set; }
|
||||
public Grn? Grn { get; set; }
|
||||
public int PurchaseInvoiceId { get; set; }
|
||||
public PurchaseInvoice? PurchaseInvoice { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime PaymentDate { get; set; }
|
||||
@@ -4,7 +4,7 @@ namespace ERPCore.Domain.Entities;
|
||||
/// A customer payment made against a Posted sales invoice's balance (installments
|
||||
/// allowed — see SalesInvoicePaymentService.PayAsync). Posts its own real GL journal
|
||||
/// entry (Debit the selected bank-or-cash account / Credit Accounts Receivable) before
|
||||
/// being recorded here — the mirror image of <see cref="GrnPayment"/>.
|
||||
/// being recorded here — the mirror image of <see cref="PurchaseInvoicePayment"/>.
|
||||
/// </summary>
|
||||
public class SalesInvoicePayment
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum PurchaseInvoiceStatus
|
||||
{
|
||||
Draft,
|
||||
Posted,
|
||||
Cancelled
|
||||
}
|
||||
@@ -17,34 +17,28 @@ public sealed record GrnLineDto(
|
||||
public sealed record GrnDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
|
||||
decimal PaidAmount, decimal BalanceAmount, IReadOnlyList<GrnLineDto> Lines);
|
||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList<GrnLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record GrnSummaryDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
|
||||
decimal PaidAmount, decimal BalanceAmount);
|
||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
||||
|
||||
public sealed record GrnConfirmResultDto(
|
||||
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo, decimal BalanceAmount,
|
||||
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo,
|
||||
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
|
||||
|
||||
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
|
||||
|
||||
public sealed record GrnPaymentDto(
|
||||
int GrnPaymentId, int GrnId, decimal Amount, DateTime PaymentDate,
|
||||
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
|
||||
|
||||
public sealed class CreateGrnPaymentRequest
|
||||
{
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
}
|
||||
/// <summary>A Confirmed, not-yet-invoiced GRN eligible to be consolidated onto a Purchase Invoice.</summary>
|
||||
public sealed record GrnEligibleForInvoiceDto(
|
||||
int GrnId, string DocNo, DateTime PostedAt, int WarehouseId,
|
||||
int LineCount, decimal Subtotal, decimal VatTotal, decimal GrandTotal);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Procurement;
|
||||
|
||||
public sealed record PurchaseInvoiceLineDto(
|
||||
int PurchaseInvoiceLineId, int GrnId, string GrnDocNo, DateTime GrnPostedAt,
|
||||
decimal Subtotal, decimal VatTotal, decimal LineTotal);
|
||||
|
||||
public sealed record PurchaseInvoiceTotalsDto(
|
||||
decimal Subtotal, decimal VatTotal, decimal GrandTotal, decimal PaidAmount, decimal BalanceAmount);
|
||||
|
||||
public sealed record PurchaseInvoiceDto(
|
||||
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
|
||||
int VendorId, string VendorSnapshotName, string? VendorSnapshotTaxNo,
|
||||
PurchaseInvoiceStatus Status, int GrnCount, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
|
||||
string? GlJournalNo, DateTime? GlPostedAt,
|
||||
PurchaseInvoiceTotalsDto Totals, IReadOnlyList<PurchaseInvoiceLineDto> Lines);
|
||||
|
||||
public sealed record PurchaseInvoiceSummaryDto(
|
||||
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
|
||||
int VendorId, string VendorSnapshotName, PurchaseInvoiceStatus Status, int GrnCount,
|
||||
PurchaseInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
|
||||
|
||||
public sealed record PurchaseInvoicePaymentDto(
|
||||
int PurchaseInvoicePaymentId, int PurchaseInvoiceId, decimal Amount, DateTime PaymentDate,
|
||||
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
|
||||
|
||||
public sealed class CreatePurchaseInvoicePaymentRequest
|
||||
{
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreatePurchaseInvoiceRequest
|
||||
{
|
||||
[Required] public int VendorId { get; set; }
|
||||
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
|
||||
[Required] public DateTime VendorInvoiceDate { get; set; }
|
||||
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UpdatePurchaseInvoiceRequest
|
||||
{
|
||||
[Required] public int VendorId { get; set; }
|
||||
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
|
||||
[Required] public DateTime VendorInvoiceDate { get; set; }
|
||||
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
|
||||
}
|
||||
@@ -17,41 +17,19 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
|
||||
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(g => g.CreatedAt).IsRequired();
|
||||
builder.Property(g => g.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(g => g.PaidAmount).HasPrecision(18, 4);
|
||||
builder.Property(g => g.BalanceAmount).HasPrecision(18, 4);
|
||||
builder.Property(g => g.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(g => g.Status);
|
||||
builder.HasIndex(g => g.PoId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnPaymentConfiguration : IEntityTypeConfiguration<GrnPayment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnPayment> builder)
|
||||
{
|
||||
builder.ToTable("grn_payments");
|
||||
builder.HasKey(p => p.GrnPaymentId);
|
||||
|
||||
builder.Property(p => p.Amount).HasPrecision(18, 4);
|
||||
builder.Property(p => p.PaymentDate).IsRequired();
|
||||
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
|
||||
builder.Property(p => p.Reference).HasMaxLength(100);
|
||||
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(p => p.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasOne(p => p.Grn).WithMany(g => g.Payments).HasForeignKey(p => p.GrnId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(p => p.GrnId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnLine> builder)
|
||||
|
||||
@@ -63,9 +63,10 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
|
||||
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 },
|
||||
new Permission { PermissionId = 35, Code = "NAV:production", NavItemId = 14 },
|
||||
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 24 },
|
||||
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 27 },
|
||||
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
|
||||
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 }
|
||||
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
|
||||
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class PurchaseInvoiceConfiguration : IEntityTypeConfiguration<PurchaseInvoice>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PurchaseInvoice> builder)
|
||||
{
|
||||
builder.ToTable("purchase_invoices");
|
||||
builder.HasKey(x => x.PurchaseInvoiceId);
|
||||
|
||||
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(x => x.DocNo).IsUnique();
|
||||
|
||||
builder.Property(x => x.VendorInvoiceNo).IsRequired().HasMaxLength(50);
|
||||
builder.Property(x => x.VendorInvoiceDate).IsRequired();
|
||||
// A vendor invoice number is only meaningful per-vendor; the service also excludes
|
||||
// Cancelled invoices from the duplicate check, so this composite index is a coarse
|
||||
// backstop (it does not distinguish Cancelled rows) rather than the sole enforcement.
|
||||
builder.HasIndex(x => new { x.VendorId, x.VendorInvoiceNo });
|
||||
|
||||
builder.HasOne(x => x.Vendor)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.VendorId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Property(x => x.VendorSnapshotName).IsRequired().HasMaxLength(200);
|
||||
builder.Property(x => x.VendorSnapshotTaxNo).HasMaxLength(50);
|
||||
|
||||
builder.Property(x => x.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(PurchaseInvoiceStatus.Draft);
|
||||
|
||||
foreach (var p in new[] { nameof(PurchaseInvoice.Subtotal), nameof(PurchaseInvoice.VatTotal), nameof(PurchaseInvoice.GrandTotal), nameof(PurchaseInvoice.PaidAmount), nameof(PurchaseInvoice.BalanceAmount) })
|
||||
builder.Property<decimal>(p).HasPrecision(18, 4);
|
||||
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
|
||||
|
||||
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
builder.Property(x => x.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasIndex(x => x.Status);
|
||||
builder.HasIndex(x => x.VendorId);
|
||||
|
||||
builder.HasMany(x => x.Lines)
|
||||
.WithOne(x => x.PurchaseInvoice)
|
||||
.HasForeignKey(x => x.PurchaseInvoiceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PurchaseInvoiceLineConfiguration : IEntityTypeConfiguration<PurchaseInvoiceLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PurchaseInvoiceLine> builder)
|
||||
{
|
||||
builder.ToTable("purchase_invoice_lines");
|
||||
builder.HasKey(x => x.PurchaseInvoiceLineId);
|
||||
|
||||
builder.Property(x => x.Subtotal).HasPrecision(18, 4);
|
||||
builder.Property(x => x.VatTotal).HasPrecision(18, 4);
|
||||
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(x => x.Grn)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.GrnId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// A GRN can be consolidated onto at most one active line at a time — cancelling the
|
||||
// invoice removes its lines and frees the GRN (PurchaseInvoiceService.CancelAsync), so
|
||||
// this stays enforceable rather than blocking a later re-invoice of the same GRN.
|
||||
builder.HasIndex(x => x.GrnId).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PurchaseInvoicePaymentConfiguration : IEntityTypeConfiguration<PurchaseInvoicePayment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PurchaseInvoicePayment> builder)
|
||||
{
|
||||
builder.ToTable("purchase_invoice_payments");
|
||||
builder.HasKey(p => p.PurchaseInvoicePaymentId);
|
||||
|
||||
builder.Property(p => p.Amount).HasPrecision(18, 4);
|
||||
builder.Property(p => p.PaymentDate).IsRequired();
|
||||
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
|
||||
builder.Property(p => p.Reference).HasMaxLength(100);
|
||||
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(p => p.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasOne(p => p.PurchaseInvoice).WithMany(x => x.Payments).HasForeignKey(p => p.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(p => p.PurchaseInvoiceId);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 28, NavItemId = 4, Code = "procurement.purchase-invoices", Label = "Purchase Invoices", Href = "/dashboard/procurement/purchase-invoices", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
|
||||
@@ -56,7 +57,11 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
|
||||
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
|
||||
new SubNavItem { SubNavItemId = 24, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
|
||||
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
|
||||
// reused by mistake in migration 20260820100345, which upserts row 24 to become the
|
||||
// notification-config item and silently overwrites this one. See migration
|
||||
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
|
||||
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
|
||||
);
|
||||
|
||||
@@ -66,7 +66,11 @@ public class ErpDbContext : DbContext
|
||||
// --- Goods Receipt (docs/10 Part C.3) ---
|
||||
public DbSet<Grn> Grns => Set<Grn>();
|
||||
public DbSet<GrnLine> GrnLines => Set<GrnLine>();
|
||||
public DbSet<GrnPayment> GrnPayments => Set<GrnPayment>();
|
||||
|
||||
// --- Purchase Invoices (vendor invoices consolidating Confirmed GRNs) ---
|
||||
public DbSet<PurchaseInvoice> PurchaseInvoices => Set<PurchaseInvoice>();
|
||||
public DbSet<PurchaseInvoiceLine> PurchaseInvoiceLines => Set<PurchaseInvoiceLine>();
|
||||
public DbSet<PurchaseInvoicePayment> PurchaseInvoicePayments => Set<PurchaseInvoicePayment>();
|
||||
|
||||
// --- Batch / Serial (docs/10 Part C.4) ---
|
||||
public DbSet<Batch> Batches => Set<Batch>();
|
||||
|
||||
Generated
+24
@@ -0,0 +1,24 @@
|
||||
// <auto-generated />
|
||||
using ERPCore.Infra.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
[DbContext(typeof(ErpDbContext))]
|
||||
[Migration("20260825000000_FixProductionTemplatesSubNavItemIdConflict")]
|
||||
partial class FixProductionTemplatesSubNavItemIdConflict
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
// This migration only repairs seed row data via raw SQL — no entity/column shape
|
||||
// changes — so it does not redeclare the full model here. The single source of
|
||||
// truth for scaffolding future migrations is ErpDbContextModelSnapshot.cs, which
|
||||
// already reflects this fix (production.templates seeded at SubNavItemId 27).
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Repairs a collision baked into migration history: 20260818090139_AddProductionNavPermissions
|
||||
/// inserted "production.templates" as sub_nav_items row 24, then
|
||||
/// 20260820100345_AddNotificationConfigModulePreferences reused the same literal id 24 for
|
||||
/// "settings.notification-config" via a raw-SQL <c>INSERT ... ON CONFLICT ("SubNavItemId") DO
|
||||
/// UPDATE</c>, silently overwriting the production.templates row instead of inserting a new
|
||||
/// one. Row 24 now correctly holds notification-config; this migration re-creates the lost
|
||||
/// production.templates row under a fresh id (27, matching the corrected seed data in
|
||||
/// SubNavItemConfiguration/PermissionConfiguration) and repoints the NAV:production.templates
|
||||
/// permission (id 36) at it.
|
||||
/// </summary>
|
||||
public partial class FixProductionTemplatesSubNavItemIdConflict : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
|
||||
VALUES (27, 14, 'production.templates', '/dashboard/production/templates', NULL, 'Templates', 1)
|
||||
ON CONFLICT ("SubNavItemId") DO NOTHING;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE permissions SET "SubNavItemId" = 27 WHERE "PermissionId" = 36;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE permissions SET "SubNavItemId" = 24 WHERE "PermissionId" = 36;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
DELETE FROM sub_nav_items WHERE "SubNavItemId" = 27;
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPurchaseInvoices : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "grn_payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BalanceAmount",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PaidAmount",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PurchaseInvoiceId",
|
||||
table: "grns",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "purchase_invoices",
|
||||
columns: table => new
|
||||
{
|
||||
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
VendorInvoiceNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
VendorInvoiceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
VendorId = table.Column<int>(type: "integer", nullable: false),
|
||||
VendorSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
VendorSnapshotTaxNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
|
||||
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
VatTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
PaidAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
BalanceAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||
GlPostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_purchase_invoices", x => x.PurchaseInvoiceId);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_invoices_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_invoices_vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "vendors",
|
||||
principalColumn: "VendorId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "purchase_invoice_lines",
|
||||
columns: table => new
|
||||
{
|
||||
PurchaseInvoiceLineId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false),
|
||||
GrnId = table.Column<int>(type: "integer", nullable: false),
|
||||
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
VatTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_purchase_invoice_lines", x => x.PurchaseInvoiceLineId);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_invoice_lines_grns_GrnId",
|
||||
column: x => x.GrnId,
|
||||
principalTable: "grns",
|
||||
principalColumn: "GrnId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_invoice_lines_purchase_invoices_PurchaseInvoiceId",
|
||||
column: x => x.PurchaseInvoiceId,
|
||||
principalTable: "purchase_invoices",
|
||||
principalColumn: "PurchaseInvoiceId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "purchase_invoice_payments",
|
||||
columns: table => new
|
||||
{
|
||||
PurchaseInvoicePaymentId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||
CreatedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_purchase_invoice_payments", x => x.PurchaseInvoicePaymentId);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_invoice_payments_purchase_invoices_PurchaseInvoice~",
|
||||
column: x => x.PurchaseInvoiceId,
|
||||
principalTable: "purchase_invoices",
|
||||
principalColumn: "PurchaseInvoiceId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_invoice_payments_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[] { 28, "procurement.purchase-invoices", "/dashboard/procurement/purchase-invoices", null, "Purchase Invoices", 4, 5 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 39, "NAV:procurement.purchase-invoices", null, 28 });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grns_PurchaseInvoiceId",
|
||||
table: "grns",
|
||||
column: "PurchaseInvoiceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_invoice_lines_GrnId",
|
||||
table: "purchase_invoice_lines",
|
||||
column: "GrnId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_invoice_lines_PurchaseInvoiceId",
|
||||
table: "purchase_invoice_lines",
|
||||
column: "PurchaseInvoiceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_invoice_payments_CreatedBy",
|
||||
table: "purchase_invoice_payments",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_invoice_payments_PurchaseInvoiceId",
|
||||
table: "purchase_invoice_payments",
|
||||
column: "PurchaseInvoiceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_invoices_CreatedBy",
|
||||
table: "purchase_invoices",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_invoices_DocNo",
|
||||
table: "purchase_invoices",
|
||||
column: "DocNo",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_invoices_Status",
|
||||
table: "purchase_invoices",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_invoices_VendorId",
|
||||
table: "purchase_invoices",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_invoices_VendorId_VendorInvoiceNo",
|
||||
table: "purchase_invoices",
|
||||
columns: new[] { "VendorId", "VendorInvoiceNo" });
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_grns_purchase_invoices_PurchaseInvoiceId",
|
||||
table: "grns",
|
||||
column: "PurchaseInvoiceId",
|
||||
principalTable: "purchase_invoices",
|
||||
principalColumn: "PurchaseInvoiceId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_grns_purchase_invoices_PurchaseInvoiceId",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "purchase_invoice_lines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "purchase_invoice_payments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "purchase_invoices");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_grns_PurchaseInvoiceId",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 39);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 28);
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PurchaseInvoiceId",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "BalanceAmount",
|
||||
table: "grns",
|
||||
type: "numeric(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PaidAmount",
|
||||
table: "grns",
|
||||
type: "numeric(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "grn_payments",
|
||||
columns: table => new
|
||||
{
|
||||
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
CreatedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
GrnId = table.Column<int>(type: "integer", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
|
||||
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_payments_grns_GrnId",
|
||||
column: x => x.GrnId,
|
||||
principalTable: "grns",
|
||||
principalColumn: "GrnId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_payments_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_payments_CreatedBy",
|
||||
table: "grn_payments",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_payments_GrnId",
|
||||
table: "grn_payments",
|
||||
column: "GrnId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1411,10 +1411,6 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
|
||||
|
||||
b.Property<decimal>("BalanceAmount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -1433,16 +1429,15 @@ namespace ERPCore.Migrations
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("PaidAmount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<int?>("PoId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("PostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("PurchaseInvoiceId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
@@ -1469,6 +1464,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasIndex("PoId");
|
||||
|
||||
b.HasIndex("PurchaseInvoiceId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
@@ -1584,55 +1581,6 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("grn_line_warranty_numbers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
|
||||
{
|
||||
b.Property<int>("GrnPaymentId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnPaymentId"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<string>("BankAccountName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("CreatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("GlBankAccountId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("GrnId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("PaymentDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("GrnPaymentId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("GrnId");
|
||||
|
||||
b.ToTable("grn_payments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b =>
|
||||
{
|
||||
b.Property<int>("HrDocumentTypeId")
|
||||
@@ -2976,7 +2924,7 @@ namespace ERPCore.Migrations
|
||||
{
|
||||
PermissionId = 36,
|
||||
Code = "NAV:production.templates",
|
||||
SubNavItemId = 24
|
||||
SubNavItemId = 27
|
||||
},
|
||||
new
|
||||
{
|
||||
@@ -2989,6 +2937,12 @@ namespace ERPCore.Migrations
|
||||
PermissionId = 38,
|
||||
Code = "NAV:production.produced",
|
||||
SubNavItemId = 26
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 39,
|
||||
Code = "NAV:procurement.purchase-invoices",
|
||||
SubNavItemId = 28
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3228,6 +3182,189 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("production_templates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
|
||||
{
|
||||
b.Property<int>("PurchaseInvoiceId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoiceId"));
|
||||
|
||||
b.Property<decimal>("BalanceAmount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("CreatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("DocNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("GrandTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("PaidAmount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Draft");
|
||||
|
||||
b.Property<decimal>("Subtotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("VatTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("VendorInvoiceDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("VendorInvoiceNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("VendorSnapshotName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("VendorSnapshotTaxNo")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("PurchaseInvoiceId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.HasIndex("VendorId", "VendorInvoiceNo");
|
||||
|
||||
b.ToTable("purchase_invoices", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b =>
|
||||
{
|
||||
b.Property<int>("PurchaseInvoiceLineId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoiceLineId"));
|
||||
|
||||
b.Property<int>("GrnId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("LineTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<int>("PurchaseInvoiceId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("Subtotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("VatTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.HasKey("PurchaseInvoiceLineId");
|
||||
|
||||
b.HasIndex("GrnId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PurchaseInvoiceId");
|
||||
|
||||
b.ToTable("purchase_invoice_lines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b =>
|
||||
{
|
||||
b.Property<int>("PurchaseInvoicePaymentId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoicePaymentId"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<string>("BankAccountName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("CreatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("GlBankAccountId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime>("PaymentDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("PurchaseInvoiceId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("PurchaseInvoicePaymentId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("PurchaseInvoiceId");
|
||||
|
||||
b.ToTable("purchase_invoice_payments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||
{
|
||||
b.Property<int>("PoId")
|
||||
@@ -5337,6 +5474,16 @@ namespace ERPCore.Migrations
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 28,
|
||||
Code = "procurement.purchase-invoices",
|
||||
Href = "/dashboard/procurement/purchase-invoices",
|
||||
Label = "Purchase Invoices",
|
||||
NavItemId = 4,
|
||||
SortOrder = 5,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 9,
|
||||
Code = "ledgers.trial-balance",
|
||||
@@ -5438,7 +5585,7 @@ namespace ERPCore.Migrations
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 24,
|
||||
SubNavItemId = 27,
|
||||
Code = "production.templates",
|
||||
Href = "/dashboard/production/templates",
|
||||
Label = "Templates",
|
||||
@@ -6176,6 +6323,11 @@ namespace ERPCore.Migrations
|
||||
.HasForeignKey("PoId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
|
||||
.WithMany()
|
||||
.HasForeignKey("PurchaseInvoiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
|
||||
.WithMany()
|
||||
.HasForeignKey("VendorId")
|
||||
@@ -6190,6 +6342,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("PurchaseInvoice");
|
||||
|
||||
b.Navigation("PurchaseOrder");
|
||||
|
||||
b.Navigation("Vendor");
|
||||
@@ -6248,25 +6402,6 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("GrnLine");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
|
||||
.WithMany("Payments")
|
||||
.HasForeignKey("GrnId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Grn");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
|
||||
@@ -6567,6 +6702,63 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Creator");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
|
||||
.WithMany()
|
||||
.HasForeignKey("VendorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Vendor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
|
||||
.WithMany()
|
||||
.HasForeignKey("GrnId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("PurchaseInvoiceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Grn");
|
||||
|
||||
b.Navigation("PurchaseInvoice");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
|
||||
.WithMany("Payments")
|
||||
.HasForeignKey("PurchaseInvoiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("PurchaseInvoice");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
@@ -7516,8 +7708,6 @@ namespace ERPCore.Migrations
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
@@ -7568,6 +7758,13 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Stages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
|
||||
@@ -105,7 +105,10 @@ builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
|
||||
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||||
builder.Services.AddScoped<IStockService, StockService>();
|
||||
builder.Services.AddScoped<IGrnService, GrnService>();
|
||||
builder.Services.AddScoped<IGrnPaymentService, GrnPaymentService>();
|
||||
|
||||
// Purchase invoices (vendor invoices consolidating Confirmed GRNs)
|
||||
builder.Services.AddScoped<IPurchaseInvoiceService, PurchaseInvoiceService>();
|
||||
builder.Services.AddScoped<IPurchaseInvoicePaymentService, PurchaseInvoicePaymentService>();
|
||||
|
||||
// Sales (Phase 1)
|
||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -73,13 +73,18 @@ public static class ErrorCodes
|
||||
// General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work)
|
||||
public const string GlRequestFailed = "GL_REQUEST_FAILED";
|
||||
|
||||
// GRN payments
|
||||
public const string GrnNotPayable = "GRN_NOT_PAYABLE";
|
||||
public const string GrnPaymentExceedsBalance = "GRN_PAYMENT_EXCEEDS_BALANCE";
|
||||
public const string GrnBankAccountNotFound = "GRN_BANK_ACCOUNT_NOT_FOUND";
|
||||
|
||||
// Sales invoice payments
|
||||
public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE";
|
||||
public const string SalesInvoicePaymentExceedsBalance = "SALES_INVOICE_PAYMENT_EXCEEDS_BALANCE";
|
||||
public const string SalesInvoiceBankAccountNotFound = "SALES_INVOICE_BANK_ACCOUNT_NOT_FOUND";
|
||||
|
||||
// Purchase invoices (consolidate Confirmed GRNs; vendor payments now tracked here, not on the GRN)
|
||||
public const string GrnAlreadyInvoiced = "GRN_ALREADY_INVOICED";
|
||||
public const string GrnVendorMismatch = "GRN_VENDOR_MISMATCH";
|
||||
public const string GrnNotConfirmed = "GRN_NOT_CONFIRMED";
|
||||
public const string PurchaseInvoiceNotEditable = "PURCHASE_INVOICE_NOT_EDITABLE";
|
||||
public const string DuplicateVendorInvoiceNo = "DUPLICATE_VENDOR_INVOICE_NO";
|
||||
public const string PurchaseInvoiceNotPayable = "PURCHASE_INVOICE_NOT_PAYABLE";
|
||||
public const string PurchaseInvoicePaymentExceedsBalance = "PURCHASE_INVOICE_PAYMENT_EXCEEDS_BALANCE";
|
||||
public const string PurchaseInvoiceBankAccountNotFound = "PURCHASE_INVOICE_BANK_ACCOUNT_NOT_FOUND";
|
||||
}
|
||||
|
||||
@@ -47,5 +47,9 @@
|
||||
"SalesInvoice": {
|
||||
"GlAccountsReceivableCode": "1300"
|
||||
},
|
||||
"PurchaseInvoice": {
|
||||
"GlClearingAccountCode": "2000",
|
||||
"GlAccountsPayableCode": "2200"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user