diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs
index 78337b9..bb5e8c2 100644
--- a/Backend/ERPCore/Controllers/GrnsController.cs
+++ b/Backend/ERPCore/Controllers/GrnsController.cs
@@ -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;
}
/// List GRNs, newest first.
@@ -64,23 +62,4 @@ public sealed class GrnsController : ApiControllerBase
public async Task> Release(
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
-
- /// Pay the vendor against this GRN's balance, in full or in installments; posts a real GL journal entry.
- [HttpPost("{grnId:int}/payments")]
- [ProducesResponseType(typeof(GrnPaymentDto), StatusCodes.Status201Created)]
- [ProducesResponseType(StatusCodes.Status400BadRequest)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- [ProducesResponseType(StatusCodes.Status409Conflict)]
- public async Task> 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);
- }
-
- /// Payment history for this GRN, newest first.
- [HttpGet("{grnId:int}/payments")]
- [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> ListPayments(int grnId, CancellationToken ct)
- => Ok(await _payments.ListAsync(grnId, ct));
}
diff --git a/Backend/ERPCore/Controllers/PurchaseInvoicesController.cs b/Backend/ERPCore/Controllers/PurchaseInvoicesController.cs
new file mode 100644
index 0000000..35a8696
--- /dev/null
+++ b/Backend/ERPCore/Controllers/PurchaseInvoicesController.cs
@@ -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;
+
+/// Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.
+[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), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] PurchaseInvoiceStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
+ => Ok(await _invoices.ListAsync(query, status, vendorId, ct));
+
+ /// Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.
+ [HttpGet("eligible-grns")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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> 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> Cancel(int purchaseInvoiceId, CancellationToken ct)
+ => Ok(await _invoices.CancelAsync(purchaseInvoiceId, ct));
+
+ /// Pay the vendor against this invoice's balance, in full or in installments; posts a real GL journal entry.
+ [HttpPost("{purchaseInvoiceId:int}/payments")]
+ [ProducesResponseType(typeof(PurchaseInvoicePaymentDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> 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);
+ }
+
+ /// Payment history for this invoice, newest first.
+ [HttpGet("{purchaseInvoiceId:int}/payments")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> ListPayments(int purchaseInvoiceId, CancellationToken ct)
+ => Ok(await _payments.ListAsync(purchaseInvoiceId, ct));
+}
diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs
index 6f39a9b..f00ad99 100644
--- a/Backend/ERPCore/Domain/DocumentTypes.cs
+++ b/Backend/ERPCore/Domain/DocumentTypes.cs
@@ -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";
diff --git a/Backend/ERPCore/Domain/Entities/Grn.cs b/Backend/ERPCore/Domain/Entities/Grn.cs
index 508a184..920d3d6 100644
--- a/Backend/ERPCore/Domain/Entities/Grn.cs
+++ b/Backend/ERPCore/Domain/Entities/Grn.cs
@@ -34,14 +34,14 @@ public class Grn
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
- /// Sum of vendor payments made against this GRN's total payable ().
- public decimal PaidAmount { get; set; }
- /// Total payable minus ; installments accrue against this until it reaches zero.
- public decimal BalanceAmount { get; set; }
+ /// 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
+ /// .
+ public int? PurchaseInvoiceId { get; set; }
+ public PurchaseInvoice? PurchaseInvoice { get; set; }
/// PostgreSQL xmin-backed optimistic concurrency token.
public uint RowVersion { get; set; }
public ICollection Lines { get; set; } = new List();
- public ICollection Payments { get; set; } = new List();
}
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseInvoice.cs b/Backend/ERPCore/Domain/Entities/PurchaseInvoice.cs
new file mode 100644
index 0000000..2e73d7f
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PurchaseInvoice.cs
@@ -0,0 +1,50 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Vendor invoice consolidating one or more Confirmed GRNs for the same vendor
+/// (each linked GRN becomes one , 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.
+///
+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; }
+
+ /// Journal number of the AP-recognition GL journal entry posted when this
+ /// invoice is Posted (Debit GRN Clearing / Credit Accounts Payable) — see
+ /// PurchaseInvoiceService.PostAsync.
+ 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 Lines { get; set; } = new List();
+ public ICollection Payments { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseInvoiceLine.cs b/Backend/ERPCore/Domain/Entities/PurchaseInvoiceLine.cs
new file mode 100644
index 0000000..a652660
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PurchaseInvoiceLine.cs
@@ -0,0 +1,21 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// One row per consolidated GRN on a — 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).
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/GrnPayment.cs b/Backend/ERPCore/Domain/Entities/PurchaseInvoicePayment.cs
similarity index 62%
rename from Backend/ERPCore/Domain/Entities/GrnPayment.cs
rename to Backend/ERPCore/Domain/Entities/PurchaseInvoicePayment.cs
index 161f71a..1a65cac 100644
--- a/Backend/ERPCore/Domain/Entities/GrnPayment.cs
+++ b/Backend/ERPCore/Domain/Entities/PurchaseInvoicePayment.cs
@@ -1,16 +1,17 @@
namespace ERPCore.Domain.Entities;
///
-/// 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.
///
-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; }
diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs b/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs
index adb2386..27705b3 100644
--- a/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs
@@ -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 .
+/// being recorded here — the mirror image of .
///
public class SalesInvoicePayment
{
diff --git a/Backend/ERPCore/Domain/Enums/PurchaseInvoiceStatus.cs b/Backend/ERPCore/Domain/Enums/PurchaseInvoiceStatus.cs
new file mode 100644
index 0000000..ee85b40
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/PurchaseInvoiceStatus.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+public enum PurchaseInvoiceStatus
+{
+ Draft,
+ Posted,
+ Cancelled
+}
diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
index f9120bf..9d6c7e8 100644
--- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
+++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
@@ -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 Lines);
+ int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList Lines);
/// Row shape for GET /grns — line count instead of the lines themselves.
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 CreatedLayers, IReadOnlyList 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; }
-}
+/// A Confirmed, not-yet-invoiced GRN eligible to be consolidated onto a Purchase Invoice.
+public sealed record GrnEligibleForInvoiceDto(
+ int GrnId, string DocNo, DateTime PostedAt, int WarehouseId,
+ int LineCount, decimal Subtotal, decimal VatTotal, decimal GrandTotal);
// Requests ----------------------------------------------------------------------
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs
new file mode 100644
index 0000000..d53b078
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs
@@ -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 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 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 GrnIds { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
index 5901e10..537922d 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
@@ -17,41 +17,19 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration
builder.Property(g => g.Status).HasConversion().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
-{
- public void Configure(EntityTypeBuilder 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
{
public void Configure(EntityTypeBuilder builder)
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
index b5198bb..7395561 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
@@ -63,9 +63,10 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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().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(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
+{
+ public void Configure(EntityTypeBuilder 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
+{
+ public void Configure(EntityTypeBuilder 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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
index 37dac63..b1e35ba 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
@@ -42,6 +42,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration Grns => Set();
public DbSet GrnLines => Set();
- public DbSet GrnPayments => Set();
+
+ // --- Purchase Invoices (vendor invoices consolidating Confirmed GRNs) ---
+ public DbSet PurchaseInvoices => Set();
+ public DbSet PurchaseInvoiceLines => Set();
+ public DbSet PurchaseInvoicePayments => Set();
// --- Batch / Serial (docs/10 Part C.4) ---
public DbSet Batches => Set();
diff --git a/Backend/ERPCore/Migrations/20260825000000_FixProductionTemplatesSubNavItemIdConflict.Designer.cs b/Backend/ERPCore/Migrations/20260825000000_FixProductionTemplatesSubNavItemIdConflict.Designer.cs
new file mode 100644
index 0000000..51431e7
--- /dev/null
+++ b/Backend/ERPCore/Migrations/20260825000000_FixProductionTemplatesSubNavItemIdConflict.Designer.cs
@@ -0,0 +1,24 @@
+//
+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
+ {
+ ///
+ 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).
+ }
+ }
+}
diff --git a/Backend/ERPCore/Migrations/20260825000000_FixProductionTemplatesSubNavItemIdConflict.cs b/Backend/ERPCore/Migrations/20260825000000_FixProductionTemplatesSubNavItemIdConflict.cs
new file mode 100644
index 0000000..1e16d06
--- /dev/null
+++ b/Backend/ERPCore/Migrations/20260825000000_FixProductionTemplatesSubNavItemIdConflict.cs
@@ -0,0 +1,46 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ERPCore.Migrations
+{
+ ///
+ /// 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 INSERT ... ON CONFLICT ("SubNavItemId") DO
+ /// UPDATE, 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.
+ ///
+ public partial class FixProductionTemplatesSubNavItemIdConflict : Migration
+ {
+ ///
+ 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;
+ """);
+ }
+
+ ///
+ 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;
+ """);
+ }
+ }
+}
diff --git a/Backend/ERPCore/Migrations/20260831060800_AddPurchaseInvoices.Designer.cs b/Backend/ERPCore/Migrations/20260831060800_AddPurchaseInvoices.Designer.cs
new file mode 100644
index 0000000..54830fd
--- /dev/null
+++ b/Backend/ERPCore/Migrations/20260831060800_AddPurchaseInvoices.Designer.cs
@@ -0,0 +1,7860 @@
+//
+using System;
+using ERPCore.Infra.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ERPCore.Migrations
+{
+ [DbContext(typeof(ErpDbContext))]
+ [Migration("20260831060800_AddPurchaseInvoices")]
+ partial class AddPurchaseInvoices
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
+ {
+ b.Property("AttendanceRecordId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceRecordId"));
+
+ b.Property("AttendanceDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AttendanceStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("AttendanceUploadBatchId")
+ .HasColumnType("integer");
+
+ b.Property("CheckIn")
+ .HasColumnType("interval");
+
+ b.Property("CheckOut")
+ .HasColumnType("interval");
+
+ b.Property("DuplicateOfAttendanceRecordId")
+ .HasColumnType("integer");
+
+ b.Property("EarlyLeaveMinutes")
+ .HasColumnType("integer");
+
+ b.Property("EditedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EditedBy")
+ .HasColumnType("integer");
+
+ b.Property("EmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("IsManualOverride")
+ .HasColumnType("boolean");
+
+ b.Property("LateMinutes")
+ .HasColumnType("integer");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("OvertimeMinutes")
+ .HasColumnType("integer");
+
+ b.Property("RowValidationStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("WorkShiftId")
+ .HasColumnType("integer");
+
+ b.Property("WorkingMinutes")
+ .HasColumnType("integer");
+
+ b.HasKey("AttendanceRecordId");
+
+ b.HasIndex("AttendanceUploadBatchId");
+
+ b.HasIndex("WorkShiftId");
+
+ b.HasIndex("EmployeeId", "AttendanceDate");
+
+ b.ToTable("hr_attendance_records", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceUploadBatch", b =>
+ {
+ b.Property("AttendanceUploadBatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceUploadBatchId"));
+
+ b.Property("ConfirmedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConfirmedBy")
+ .HasColumnType("integer");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(260)
+ .HasColumnType("character varying(260)");
+
+ b.Property("PeriodEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PeriodStart")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowCountDuplicate")
+ .HasColumnType("integer");
+
+ b.Property("RowCountError")
+ .HasColumnType("integer");
+
+ b.Property("RowCountTotal")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SourceType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UploadedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UploadedBy")
+ .HasColumnType("integer");
+
+ b.HasKey("AttendanceUploadBatchId");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.HasIndex("PeriodStart", "PeriodEnd");
+
+ b.ToTable("hr_attendance_upload_batches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b =>
+ {
+ b.Property("AuditId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("ChangeSet")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EntityId")
+ .HasColumnType("integer");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("AuditId");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("audit_logs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
+ {
+ b.Property("BatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId"));
+
+ b.Property("BatchNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ExpiryDate")
+ .HasColumnType("date");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.HasKey("BatchId");
+
+ b.HasIndex("ItemId", "BatchNo")
+ .IsUnique();
+
+ b.ToTable("batches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
+ {
+ b.Property("BinId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId"));
+
+ b.Property("BinType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BinId");
+
+ b.HasIndex("WarehouseId", "Code")
+ .IsUnique();
+
+ b.ToTable("bins", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Branch", b =>
+ {
+ b.Property("BranchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BranchId"));
+
+ b.Property("Address")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BranchId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_branches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
+ {
+ b.Property("BrandId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BrandId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("brands", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
+ {
+ b.Property("BundleSaleId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleId"));
+
+ b.Property("BundleCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BundleDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("BundleName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BundleNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BundlePrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("integer");
+
+ b.Property("CashierUserId")
+ .HasColumnType("integer");
+
+ b.Property("ComponentSubtotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer");
+
+ b.Property("CustomerSnapshotName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DayEndId")
+ .HasColumnType("integer");
+
+ b.Property("DiscountTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("GrandTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("MarginAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Draft");
+
+ b.Property("TaxTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BundleSaleId");
+
+ b.HasIndex("BundleNo")
+ .IsUnique();
+
+ b.HasIndex("BundleSaleTemplateId");
+
+ b.HasIndex("CashierUserId");
+
+ b.HasIndex("CustomerId");
+
+ b.HasIndex("DayEndId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sales", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
+ {
+ b.Property("BundleSaleLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleLineId"));
+
+ b.Property("BundleSaleId")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("IncludeInBundle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("IsComponent")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("LineTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ParentLineId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BundleSaleLineId");
+
+ b.HasIndex("BundleSaleId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sale_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
+ {
+ b.Property("BundleSaleTemplateId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleTemplateId"));
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TemplateCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("TemplateName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BundleSaleTemplateId");
+
+ b.HasIndex("TemplateCode")
+ .IsUnique();
+
+ b.ToTable("bundle_sale_templates", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
+ {
+ b.Property("BundleSaleTemplateLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleTemplateLineId"));
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("integer");
+
+ b.Property("IncludeInBundle")
+ .HasColumnType("boolean");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("SortOrder")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BundleSaleTemplateLineId");
+
+ b.HasIndex("BundleSaleTemplateId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sale_template_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
+ {
+ b.Property("CategoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("CategoryId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("categories", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
+ {
+ b.Property("CustomerId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CustomerId"));
+
+ b.Property("AddressLine1")
+ .HasMaxLength(250)
+ .HasColumnType("character varying(250)");
+
+ b.Property("AddressLine2")
+ .HasMaxLength(250)
+ .HasColumnType("character varying(250)");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreditDays")
+ .HasColumnType("integer");
+
+ b.Property("CreditLimit")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("CustomerCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("CustomerType")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("B2C");
+
+ b.Property("DefaultWarehouseId")
+ .HasColumnType("integer");
+
+ b.Property("DisplayName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Email")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Phone")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TaxRegistrationNo")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("CustomerId");
+
+ b.HasIndex("CustomerCode")
+ .IsUnique();
+
+ b.HasIndex("CustomerType");
+
+ b.HasIndex("DefaultWarehouseId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("customers", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Department", b =>
+ {
+ b.Property("DepartmentId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DepartmentId"));
+
+ b.Property("BranchId")
+ .HasColumnType("integer");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("HeadEmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ParentDepartmentId")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("DepartmentId");
+
+ b.HasIndex("BranchId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("HeadEmployeeId");
+
+ b.HasIndex("ParentDepartmentId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_departments", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Designation", b =>
+ {
+ b.Property("DesignationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DesignationId"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("DesignationId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_designations", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b =>
+ {
+ b.Property("EmployeeId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeId"));
+
+ b.Property("AddressLine1")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("AddressLine2")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BranchId")
+ .HasColumnType("integer");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ConfirmationDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("integer");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DepartmentId")
+ .HasColumnType("integer");
+
+ b.Property("DesignationId")
+ .HasColumnType("integer");
+
+ b.Property("Email")
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)");
+
+ b.Property("EmergencyContactName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("EmergencyContactPhone")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EmergencyContactRelationship")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("EmployeeCode")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EmploymentTypeId")
+ .HasColumnType("integer");
+
+ b.Property("EpfNumber")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EtfNumber")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Gender")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("HireDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastWorkingDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Nationality")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Nic")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property