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 bb5e8c2..1ba4861 100644
--- a/Backend/ERPCore/Controllers/GrnsController.cs
+++ b/Backend/ERPCore/Controllers/GrnsController.cs
@@ -22,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)]
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/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/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/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/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/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/PermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
index 7395561..2320857 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
@@ -66,7 +66,8 @@ public sealed class PermissionConfiguration : 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 25d5060..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();
diff --git a/Backend/ERPCore/Migrations/20260901104702_AddBranchPhoneNumberAndCodeAutogen.Designer.cs b/Backend/ERPCore/Migrations/20260901104702_AddBranchPhoneNumberAndCodeAutogen.Designer.cs
new file mode 100644
index 0000000..d13444b
--- /dev/null
+++ b/Backend/ERPCore/Migrations/20260901104702_AddBranchPhoneNumberAndCodeAutogen.Designer.cs
@@ -0,0 +1,7880 @@
+//
+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("20260901104702_AddBranchPhoneNumberAndCodeAutogen")]
+ partial class AddBranchPhoneNumberAndCodeAutogen
+ {
+ ///
+ 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("PhoneNumber")
+ .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("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("PersonalMobile")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("PostalCode")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("ProfilePhotoPath")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ReportingManagerId")
+ .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("TaxIdentificationNumber")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.Property("WorkShiftId")
+ .HasColumnType("integer");
+
+ b.HasKey("EmployeeId");
+
+ b.HasIndex("BranchId");
+
+ b.HasIndex("DepartmentId");
+
+ b.HasIndex("DesignationId");
+
+ b.HasIndex("Email");
+
+ b.HasIndex("EmployeeCode")
+ .IsUnique();
+
+ b.HasIndex("EmploymentTypeId");
+
+ b.HasIndex("ReportingManagerId");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.HasIndex("WorkShiftId");
+
+ b.ToTable("hr_employees", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b =>
+ {
+ b.Property("EmployeeBankDetailId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeBankDetailId"));
+
+ b.Property("AccountHolderName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("AccountNumber")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BankName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BranchName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("IsPrimary")
+ .HasColumnType("boolean");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("SwiftCode")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("EmployeeBankDetailId");
+
+ b.HasIndex("EmployeeId");
+
+ b.ToTable("hr_employee_bank_details", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b =>
+ {
+ b.Property("EmployeeDocumentId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeDocumentId"));
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("EmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("ExpiryDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("HrDocumentTypeId")
+ .HasColumnType("integer");
+
+ b.Property("IssueDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property