diff --git a/Backend/ERPCore/Controllers/BranchConfigController.cs b/Backend/ERPCore/Controllers/BranchConfigController.cs
new file mode 100644
index 0000000..8ea5430
--- /dev/null
+++ b/Backend/ERPCore/Controllers/BranchConfigController.cs
@@ -0,0 +1,44 @@
+using ERPCore.Dtos.Config;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Branch configuration endpoints — the singleton toggle for whether this deployment
+/// requires a branch on Employee/Warehouse records at all.
+///
+/// Authorization: writes are admitted by the inherited ERP door policy only, same
+/// Phase-1 posture as — any ERP-admitted user can
+/// flip this flag until per-endpoint RBAC lands.
+///
+///
+[Route("api/v1/branch-config")]
+public sealed class BranchConfigController : ApiControllerBase
+{
+ private readonly IBranchConfigService _config;
+
+ public BranchConfigController(IBranchConfigService config) => _config = config;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
+ public async Task> Get(CancellationToken ct)
+ {
+ var result = await _config.GetAsync(ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPut]
+ [ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> Update(
+ [FromBody] UpdateBranchConfigRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _config.UpdateAsync(request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/DashboardController.cs b/Backend/ERPCore/Controllers/DashboardController.cs
index b10062f..eeaf72d 100644
--- a/Backend/ERPCore/Controllers/DashboardController.cs
+++ b/Backend/ERPCore/Controllers/DashboardController.cs
@@ -15,6 +15,6 @@ public sealed class DashboardController : ApiControllerBase
/// Aggregate counts for stock, GRN, and procurement.
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
- public async Task> GetStats(CancellationToken ct)
- => Ok(await _dashboard.GetStatsAsync(ct));
+ public async Task> GetStats([FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _dashboard.GetStatsAsync(branchId, ct));
}
diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs
index 78337b9..1ba4861 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.
@@ -24,8 +22,8 @@ public sealed class GrnsController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
- [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
- => Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
+ [FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
@@ -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/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
index 9af14b7..3720f0d 100644
--- a/Backend/ERPCore/Controllers/SalesInvoicesController.cs
+++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
@@ -25,8 +25,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
+ [FromQuery] int? branchId,
CancellationToken ct)
- => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
+ => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
index 51b2544..f8a1b52 100644
--- a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
+++ b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
@@ -17,8 +17,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> List(
- [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
- => Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
+ [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
+ => Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
/// Get one adjustment with its lines and the ledger entries it posted.
[HttpGet("{adjustmentId:int}")]
diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs
index 3189268..609ac08 100644
--- a/Backend/ERPCore/Controllers/StockController.cs
+++ b/Backend/ERPCore/Controllers/StockController.cs
@@ -28,8 +28,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> OnHandList(
- [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
- => Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
+ [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, branchId, query, ct));
///
/// Immutable movement history. sourceDocType/sourceDocId answer "what did
@@ -39,11 +39,11 @@ public sealed class StockController : ApiControllerBase
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> Ledger(
- [FromQuery] int? itemId, [FromQuery] int? warehouseId,
+ [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
- => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
+ => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -54,8 +54,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("reorder-alerts")]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> ReorderAlerts(
- [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
- => Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
+ [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _reorder.GetAlertsAsync(warehouseId, branchId, query, ct));
/// Create a draft requisition for an item's suggested reorder quantity.
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
diff --git a/Backend/ERPCore/Controllers/StockCountsController.cs b/Backend/ERPCore/Controllers/StockCountsController.cs
index aa8b477..3802f1f 100644
--- a/Backend/ERPCore/Controllers/StockCountsController.cs
+++ b/Backend/ERPCore/Controllers/StockCountsController.cs
@@ -18,8 +18,8 @@ public sealed class StockCountsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> List(
- [FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
- => Ok(await _counts.ListAsync(query, status, warehouseId, ct));
+ [FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _counts.ListAsync(query, status, warehouseId, branchId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Controllers/StockTransfersController.cs b/Backend/ERPCore/Controllers/StockTransfersController.cs
index 49d1a3f..67d9525 100644
--- a/Backend/ERPCore/Controllers/StockTransfersController.cs
+++ b/Backend/ERPCore/Controllers/StockTransfersController.cs
@@ -19,8 +19,8 @@ public sealed class StockTransfersController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
- [FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
- => Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
+ [FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, [FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, branchId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Controllers/UsersController.cs b/Backend/ERPCore/Controllers/UsersController.cs
index bb1e3cf..d943ab0 100644
--- a/Backend/ERPCore/Controllers/UsersController.cs
+++ b/Backend/ERPCore/Controllers/UsersController.cs
@@ -1,6 +1,7 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
+using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -15,11 +16,13 @@ public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
+ private readonly ICurrentUser _currentUser;
- public UsersController(IUserManagementService users, IEmployeeUserLinkService links)
+ public UsersController(IUserManagementService users, IEmployeeUserLinkService links, ICurrentUser currentUser)
{
_users = users;
_links = links;
+ _currentUser = currentUser;
}
/// Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)
@@ -53,7 +56,7 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
- var result = await _users.CreateAsync(request, ct);
+ var result = await _users.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/users/{result.UserId}", result);
}
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/Branch.cs b/Backend/ERPCore/Domain/Entities/Branch.cs
index 7a71081..bf71632 100644
--- a/Backend/ERPCore/Domain/Entities/Branch.cs
+++ b/Backend/ERPCore/Domain/Entities/Branch.cs
@@ -3,16 +3,19 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
///
-/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced
-/// optionally by and .
+/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Required by
+/// , optionally referenced by .
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
///
public class Branch
{
public int BranchId { get; set; }
+
+ /// Server-generated on create ("BR-001", "BR-002", ...) — never accepted from the client.
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Address { get; set; }
+ public string? PhoneNumber { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/BranchConfig.cs b/Backend/ERPCore/Domain/Entities/BranchConfig.cs
new file mode 100644
index 0000000..37d83c0
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/BranchConfig.cs
@@ -0,0 +1,27 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Branch configuration — a singleton row (single-tenant, docs/00-CORE §1) gating whether
+/// this deployment uses branches at all. When is off, the
+/// Employee/Warehouse create forms stop asking for a branch and every new record is
+/// silently assigned to the deployment's one default branch instead — schema-wise
+/// Employee.BranchId/Warehouse.BranchId stay NOT NULL either way, only
+/// the UI/UX requirement toggles. See .
+///
+public class BranchConfig
+{
+ /// Always 1 - the singleton row's id.
+ public const int SingletonId = 1;
+
+ public int ConfigId { get; set; }
+
+ public bool BranchingEnabled { get; set; } = true;
+
+ public DateTime? UpdatedAt { get; set; }
+
+ public int? UpdatedBy { get; set; }
+ public User? UpdatedByUser { get; set; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Employee.cs b/Backend/ERPCore/Domain/Entities/Employee.cs
index 2248904..f605a7f 100644
--- a/Backend/ERPCore/Domain/Entities/Employee.cs
+++ b/Backend/ERPCore/Domain/Entities/Employee.cs
@@ -50,7 +50,7 @@ public class Employee
public Designation? Designation { get; set; }
public int EmploymentTypeId { get; set; }
public EmploymentType? EmploymentType { get; set; }
- public int? BranchId { get; set; }
+ public int BranchId { get; set; }
public Branch? Branch { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
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/Entities/Warehouse.cs b/Backend/ERPCore/Domain/Entities/Warehouse.cs
index bbcd8bf..2c6a3ab 100644
--- a/Backend/ERPCore/Domain/Entities/Warehouse.cs
+++ b/Backend/ERPCore/Domain/Entities/Warehouse.cs
@@ -9,6 +9,8 @@ public class Warehouse
public int WarehouseId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
public ICollection Bins { get; set; } = new List();
}
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/Config/BranchConfigDtos.cs b/Backend/ERPCore/Dtos/Config/BranchConfigDtos.cs
new file mode 100644
index 0000000..26976c8
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Config/BranchConfigDtos.cs
@@ -0,0 +1,20 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Config;
+
+/// Branch configuration resource — the singleton toggle for whether this deployment
+/// requires a branch on Employee/Warehouse records at all.
+public sealed record BranchConfigDto(bool BranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
+
+///
+/// Full replacement of the flag. UpdatedBy is derived from the token, never posted.
+///
+/// is ? deliberately: [Required] on a
+/// non-nullable bool is a no-op, so an omitted flag would otherwise silently bind to
+/// false. Nullable makes the requirement actually bind — an omitted flag is a 400.
+///
+///
+public sealed class UpdateBranchConfigRequest
+{
+ [Required] public bool? BranchingEnabled { get; set; }
+}
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/Hrm/EmployeeDtos.cs b/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
index 2be6c3a..8c9e445 100644
--- a/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
+++ b/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Hrm;
public sealed record EmployeeListItemDto(
int EmployeeId, string EmployeeCode, string FullName, string? Email,
int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName,
- int EmploymentTypeId, string? EmploymentTypeName, int? BranchId, string? BranchName,
+ int EmploymentTypeId, string? EmploymentTypeName, int BranchId, string? BranchName,
EmployeeStatus Status, bool HasUserLink, DateTime HireDate);
public sealed record EmployeeDetailDto(
@@ -16,7 +16,7 @@ public sealed record EmployeeDetailDto(
string? City, string? PostalCode, string? Country,
string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone,
DateTime HireDate, DateTime? ConfirmationDate, DateTime? LastWorkingDate,
- int DepartmentId, int DesignationId, int EmploymentTypeId, int? BranchId, int WorkShiftId,
+ int DepartmentId, int DesignationId, int EmploymentTypeId, int BranchId, int WorkShiftId,
int? ReportingManagerId, string? EpfNumber, string? EtfNumber, string? TaxIdentificationNumber,
int? UserId, EmployeeStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
@@ -45,7 +45,7 @@ public sealed class CreateEmployeeRequest
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
- public int? BranchId { get; set; }
+ [Required] public int BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
@@ -82,7 +82,7 @@ public sealed class UpdateEmployeeRequest
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
- public int? BranchId { get; set; }
+ [Required] public int BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
diff --git a/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs b/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs
index c90c9eb..ba9f667 100644
--- a/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs
+++ b/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs
@@ -7,20 +7,22 @@ namespace ERPCore.Dtos.Hrm;
// ids, timestamps) excluded from create/update requests (02-SECURITY B.6/C.1).
public sealed record BranchDto(
- int BranchId, string Code, string Name, string? Address, EntityStatus Status,
+ int BranchId, string Code, string Name, string? Address, string? PhoneNumber, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
+// Code is server-generated (BranchService.GenerateCodeAsync) — never accepted from the client.
public sealed class CreateBranchRequest
{
- [Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
+ [StringLength(30)] public string? PhoneNumber { get; set; }
}
public sealed class UpdateBranchRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
+ [StringLength(30)] public string? PhoneNumber { get; set; }
}
public sealed record DepartmentDto(
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/Dtos/Rbac/MeDtos.cs b/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
index 9396bcf..f56c32f 100644
--- a/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
+++ b/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
@@ -2,5 +2,13 @@ namespace ERPCore.Dtos.Rbac;
/// Response for `GET /api/v1/auth/me` — the frontend's authoritative source
/// for the current user's role and permitted sidebar nav codes (replaces trusting
-/// the stale, client-only `roleId` cached in localStorage).
-public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList NavCodes);
+/// the stale, client-only `roleId` cached in localStorage). /
+/// come from the caller's linked Employee record, when
+/// one exists — null for a login with no Employee link. gates
+/// branch pickers client-side only (no server-side branch enforcement exists yet,
+/// see docs/BRANCH_ISOLATION_PLAN.md). mirrors the
+/// singleton BranchConfig toggle — when false the frontend hides branch pickers
+/// entirely rather than merely restricting them by role.
+public sealed record MeResponseDto(
+ string? RoleCode, string? RoleName, IReadOnlyList NavCodes,
+ bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled);
diff --git a/Backend/ERPCore/Dtos/Users/UserDtos.cs b/Backend/ERPCore/Dtos/Users/UserDtos.cs
index 2de2099..91ac63d 100644
--- a/Backend/ERPCore/Dtos/Users/UserDtos.cs
+++ b/Backend/ERPCore/Dtos/Users/UserDtos.cs
@@ -30,6 +30,22 @@ public sealed class CreateUserRequest
/// email-lookup — never automatic, even on an exact email match (docs/12-BACKEND-HRM.md A.5).
///
public int? LinkEmployeeId { get; set; }
+
+ ///
+ /// Required, together with //
+ /// //, whenever
+ /// the assigned Role is not Admin and is not supplied: creates
+ /// the linked Employee record whose BranchId is what actually scopes this account to one
+ /// branch (see BranchAccessService.EnsureBranchVisibleAsync).
+ ///
+ public string? EmployeeCode { get; set; }
+ public int? BranchId { get; set; }
+ public int? DepartmentId { get; set; }
+ public int? DesignationId { get; set; }
+ public int? EmploymentTypeId { get; set; }
+ public int? WorkShiftId { get; set; }
+ /// Defaults to today when omitted.
+ public DateTime? HireDate { get; set; }
}
public sealed class UpdateUserRoleRequest
diff --git a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
index 37dea5c..83e15f9 100644
--- a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
+++ b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
@@ -2,8 +2,10 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Warehouses;
-/// Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).
-public sealed record WarehouseDto(int WarehouseId, string Code, string Name);
+/// Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5). is
+/// the anchor `docs/BRANCH_ISOLATION_PLAN.md` uses to branch-scope everything that carries
+/// a `warehouseId` (Sales, GRN, stock, ...) without a column on every one of those entities.
+public sealed record WarehouseDto(int WarehouseId, string Code, string Name, int BranchId, string? BranchName);
/// Bin/location resource (docs/11 §2.5).
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
@@ -12,6 +14,7 @@ public sealed class CreateWarehouseRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
+ [Required] public int BranchId { get; set; }
}
public sealed class CreateBinRequest
diff --git a/Backend/ERPCore/Infra/Auth/CurrentUser.cs b/Backend/ERPCore/Infra/Auth/CurrentUser.cs
index 9e0a0b1..1d7340a 100644
--- a/Backend/ERPCore/Infra/Auth/CurrentUser.cs
+++ b/Backend/ERPCore/Infra/Auth/CurrentUser.cs
@@ -31,4 +31,8 @@ public sealed class CurrentUser : ICurrentUser
}
public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
+
+ public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.RoleCode);
+
+ public bool IsAdmin => string.Equals(RoleCode?.Trim(), "Admin", StringComparison.OrdinalIgnoreCase);
}
diff --git a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
index 1c103a2..78da05e 100644
--- a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
+++ b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
@@ -19,4 +19,14 @@ public interface ICurrentUser
/// True when the request carries an authenticated principal.
bool IsAuthenticated { get; }
+
+ /// The AuthHex RoleCode claim verbatim, or null if absent/unauthenticated.
+ string? RoleCode { get; }
+
+ ///
+ /// True when is "Admin" (case-insensitive — AuthHex mints the claim
+ /// independently of ERPCore's stored casing). Mirrors RoleService.GetMeAsync's check;
+ /// the only role that bypasses branch-scoping (see ).
+ ///
+ bool IsAdmin { get; }
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfigConfiguration.cs
new file mode 100644
index 0000000..e60df23
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfigConfiguration.cs
@@ -0,0 +1,33 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+///
+/// Configures the singleton branch-configuration row. The check constraint is what makes
+/// "singleton" a database guarantee rather than a convention (mirrors ProductConfig).
+///
+public sealed class BranchConfigConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("branch_config", t =>
+ t.HasCheckConstraint("ck_branch_config_singleton", $"\"ConfigId\" = {BranchConfig.SingletonId}"));
+
+ builder.HasKey(c => c.ConfigId);
+
+ // The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
+ builder.Property(c => c.ConfigId).ValueGeneratedNever();
+
+ builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true);
+
+ // PostgreSQL xmin system column as the optimistic concurrency token (ETag).
+ builder.Property(c => c.RowVersion).IsRowVersion();
+
+ builder.HasOne(c => c.UpdatedByUser)
+ .WithMany()
+ .HasForeignKey(c => c.UpdatedBy)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs
index 660539e..daaad30 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs
@@ -16,6 +16,7 @@ public sealed class BranchConfiguration : IEntityTypeConfiguration
builder.HasIndex(b => b.Code).IsUnique();
builder.Property(b => b.Name).IsRequired().HasMaxLength(200);
builder.Property(b => b.Address).HasMaxLength(500);
+ builder.Property(b => b.PhoneNumber).HasMaxLength(30);
builder.Property(b => b.Status)
.HasConversion().HasMaxLength(20).IsRequired()
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..2320857 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
@@ -63,9 +63,11 @@ 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..875a8cd 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
@@ -31,6 +31,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration
builder.HasIndex(w => w.Code).IsUnique();
builder.Property(w => w.Name).IsRequired().HasMaxLength(200);
+
+ builder.HasOne(w => w.Branch).WithMany()
+ .HasForeignKey(w => w.BranchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasIndex(w => w.BranchId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
index 21a0b01..eea8688 100644
--- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
+++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
@@ -45,6 +45,7 @@ public static class DataSeeder
dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
+ dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
@@ -110,6 +111,23 @@ public static class DataSeeder
return true;
}
+ ///
+ /// Ensures the singleton branch-config row exists. New deployments start with branching
+ /// on — today's mandatory-branch behaviour — so nothing changes for anyone until an
+ /// admin deliberately switches it off from Settings.
+ ///
+ private static async Task SeedBranchConfigAsync(ErpDbContext db, CancellationToken ct)
+ {
+ if (await db.BranchConfig.AnyAsync(c => c.ConfigId == BranchConfig.SingletonId, ct)) return false;
+
+ db.BranchConfig.Add(new BranchConfig
+ {
+ ConfigId = BranchConfig.SingletonId,
+ BranchingEnabled = true
+ });
+ return true;
+ }
+
private static async Task SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index 56ece71..99b790a 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -38,6 +38,8 @@ public class ErpDbContext : DbContext
/// Singleton row (FR-MD-11).
public DbSet ProductConfig => Set();
+ /// Singleton row gating whether this deployment uses branches at all.
+ public DbSet BranchConfig => Set();
/// Singleton row for notification delivery preferences.
public DbSet NotificationConfig => Set();
public DbSet NotificationConfigModules => Set();
@@ -66,7 +68,11 @@ public class ErpDbContext : DbContext
// --- Goods Receipt (docs/10 Part C.3) ---
public DbSet 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