branch crud and intergration

This commit is contained in:
2026-09-08 12:13:49 +05:30
parent bebec10eb3
commit f664961fce
113 changed files with 41598 additions and 174 deletions
@@ -0,0 +1,44 @@
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Branch configuration endpoints — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.
/// <para>
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only, same
/// Phase-1 posture as <see cref="ProductConfigController"/> — any ERP-admitted user can
/// flip this flag until per-endpoint RBAC lands.
/// </para>
/// </summary>
[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<ActionResult<BranchConfigDto>> 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<ActionResult<BranchConfigDto>> Update(
[FromBody] UpdateBranchConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -15,6 +15,6 @@ public sealed class DashboardController : ApiControllerBase
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(ct));
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
}
@@ -22,8 +22,8 @@ public sealed class GrnsController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> 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)]
@@ -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)]
@@ -17,8 +17,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> 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));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
@@ -28,8 +28,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> 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));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
@@ -39,11 +39,11 @@ public sealed class StockController : ApiControllerBase
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> 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<ReorderAlertDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> 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));
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
@@ -18,8 +18,8 @@ public sealed class StockCountsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> 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)]
@@ -19,8 +19,8 @@ public sealed class StockTransfersController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> 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)]
@@ -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;
}
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
@@ -53,7 +56,7 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManagedUserDto>> 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);
}
+5 -2
View File
@@ -3,16 +3,19 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced
/// optionally by <see cref="Employee.BranchId"/> and <see cref="PayrollRun.BranchId"/>.
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Required by
/// <see cref="Employee.BranchId"/>, optionally referenced by <see cref="PayrollRun.BranchId"/>.
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Branch
{
public int BranchId { get; set; }
/// <summary>Server-generated on create ("BR-001", "BR-002", ...) — never accepted from the client.</summary>
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; }
@@ -0,0 +1,27 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch configuration — a singleton row (single-tenant, docs/00-CORE §1) gating whether
/// this deployment uses branches at all. When <see cref="BranchingEnabled"/> 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
/// <c>Employee.BranchId</c>/<c>Warehouse.BranchId</c> stay <c>NOT NULL</c> either way, only
/// the UI/UX requirement toggles. See <see cref="Services.BranchConfigService.ResolveBranchIdAsync"/>.
/// </summary>
public class BranchConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
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; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
+1 -1
View File
@@ -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; }
@@ -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<Bin> Bins { get; set; } = new List<Bin>();
}
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Branch configuration resource — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.</summary>
public sealed record BranchConfigDto(bool BranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted.
/// <para>
/// <see cref="BranchingEnabled"/> is <see cref="bool"/>? deliberately: <c>[Required]</c> on a
/// non-nullable bool is a no-op, so an omitted flag would otherwise silently bind to
/// <c>false</c>. Nullable makes the requirement actually bind — an omitted flag is a 400.
/// </para>
/// </summary>
public sealed class UpdateBranchConfigRequest
{
[Required] public bool? BranchingEnabled { get; set; }
}
+4 -4
View File
@@ -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; }
+4 -2
View File
@@ -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(
+10 -2
View File
@@ -2,5 +2,13 @@ namespace ERPCore.Dtos.Rbac;
/// <summary>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).</summary>
public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes);
/// the stale, client-only `roleId` cached in localStorage). <see cref="BranchId"/>/
/// <see cref="BranchName"/> come from the caller's linked <c>Employee</c> record, when
/// one exists — null for a login with no Employee link. <see cref="IsAdmin"/> gates
/// branch pickers client-side only (no server-side branch enforcement exists yet,
/// see docs/BRANCH_ISOLATION_PLAN.md). <see cref="BranchingEnabled"/> mirrors the
/// singleton <c>BranchConfig</c> toggle — when false the frontend hides branch pickers
/// entirely rather than merely restricting them by role.</summary>
public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled);
+16
View File
@@ -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).
/// </summary>
public int? LinkEmployeeId { get; set; }
/// <summary>
/// Required, together with <see cref="DepartmentId"/>/<see cref="DesignationId"/>/
/// <see cref="EmploymentTypeId"/>/<see cref="WorkShiftId"/>/<see cref="BranchId"/>, whenever
/// the assigned Role is not Admin and <see cref="LinkEmployeeId"/> is not supplied: creates
/// the linked Employee record whose BranchId is what actually scopes this account to one
/// branch (see BranchAccessService.EnsureBranchVisibleAsync).
/// </summary>
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; }
/// <summary>Defaults to today when omitted.</summary>
public DateTime? HireDate { get; set; }
}
public sealed class UpdateUserRoleRequest
@@ -2,8 +2,10 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Warehouses;
/// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public sealed record WarehouseDto(int WarehouseId, string Code, string Name);
/// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5). <see cref="BranchId"/> 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.</summary>
public sealed record WarehouseDto(int WarehouseId, string Code, string Name, int BranchId, string? BranchName);
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
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
@@ -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);
}
@@ -19,4 +19,14 @@ public interface ICurrentUser
/// <summary>True when the request carries an authenticated principal.</summary>
bool IsAuthenticated { get; }
/// <summary>The AuthHex <c>RoleCode</c> claim verbatim, or null if absent/unauthenticated.</summary>
string? RoleCode { get; }
/// <summary>
/// True when <see cref="RoleCode"/> is "Admin" (case-insensitive — AuthHex mints the claim
/// independently of ERPCore's stored casing). Mirrors <c>RoleService.GetMeAsync</c>'s check;
/// the only role that bypasses branch-scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
/// </summary>
bool IsAdmin { get; }
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton branch-configuration row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors ProductConfig).
/// </summary>
public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchConfig>
{
public void Configure(EntityTypeBuilder<BranchConfig> 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);
}
}
@@ -16,6 +16,7 @@ public sealed class BranchConfiguration : IEntityTypeConfiguration<Branch>
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<string>().HasMaxLength(20).IsRequired()
@@ -66,7 +66,8 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 27 },
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 }
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 },
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 }
);
}
}
@@ -31,6 +31,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 4, NavItemId = 2, Code = "products.item-type", Label = "Item Type", Href = "/dashboard/products/item-types", SortOrder = 4 },
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 29, NavItemId = 9, Code = "settings.branches", Label = "Branches", Href = "/dashboard/settings/branches", SortOrder = 0 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
@@ -15,5 +15,9 @@ public sealed class WarehouseConfiguration : IEntityTypeConfiguration<Warehouse>
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);
}
}
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
private static async Task<bool> 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<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
@@ -38,6 +38,8 @@ public class ErpDbContext : DbContext
/// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBranchPhoneNumberAndCodeAutogen : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PhoneNumber",
table: "hr_branches",
type: "character varying(30)",
maxLength: 30,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PhoneNumber",
table: "hr_branches");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSettingsBranchesNavItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 29, "settings.branches", "/dashboard/settings/branches", null, "Branches", 9, 0 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 40, "NAV:settings.branches", null, 29 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 40);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 29);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class MakeEmployeeBranchRequired : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Branch becomes a required field on Employee going forward. Any pre-existing
// employee left with no branch is backfilled to a "Main Branch" placeholder
// (created only if it doesn't already exist) so the NOT NULL below never fails
// on live data.
migrationBuilder.Sql(@"
INSERT INTO hr_branches (""Code"", ""Name"", ""Status"", ""CreatedAt"")
SELECT 'BR-000', 'Main Branch', 'Active', now()
WHERE NOT EXISTS (SELECT 1 FROM hr_branches WHERE ""Code"" = 'BR-000')
AND EXISTS (SELECT 1 FROM hr_employees WHERE ""BranchId"" IS NULL);
UPDATE hr_employees
SET ""BranchId"" = (SELECT ""BranchId"" FROM hr_branches WHERE ""Code"" = 'BR-000')
WHERE ""BranchId"" IS NULL;
");
migrationBuilder.AlterColumn<int>(
name: "BranchId",
table: "hr_employees",
type: "integer",
nullable: false,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "BranchId",
table: "hr_employees",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddWarehouseBranch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "warehouses",
type: "integer",
nullable: true);
// Backfill any pre-existing warehouse onto the same "Main Branch" placeholder
// used by the earlier MakeEmployeeBranchRequired migration (created only if it
// doesn't already exist) — keeps a single de-facto default rather than minting
// a second one — before the column is locked to NOT NULL below.
migrationBuilder.Sql(@"
INSERT INTO hr_branches (""Code"", ""Name"", ""Status"", ""CreatedAt"")
SELECT 'BR-000', 'Main Branch', 'Active', now()
WHERE NOT EXISTS (SELECT 1 FROM hr_branches WHERE ""Code"" = 'BR-000')
AND EXISTS (SELECT 1 FROM warehouses WHERE ""BranchId"" IS NULL);
UPDATE warehouses
SET ""BranchId"" = (SELECT ""BranchId"" FROM hr_branches WHERE ""Code"" = 'BR-000')
WHERE ""BranchId"" IS NULL;
");
migrationBuilder.AlterColumn<int>(
name: "BranchId",
table: "warehouses",
type: "integer",
nullable: false,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_warehouses_BranchId",
table: "warehouses",
column: "BranchId");
migrationBuilder.AddForeignKey(
name: "FK_warehouses_hr_branches_BranchId",
table: "warehouses",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_warehouses_hr_branches_BranchId",
table: "warehouses");
migrationBuilder.DropIndex(
name: "IX_warehouses_BranchId",
table: "warehouses");
migrationBuilder.DropColumn(
name: "BranchId",
table: "warehouses");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBranchConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "branch_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "integer", nullable: false),
BranchingEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_branch_config", x => x.ConfigId);
table.CheckConstraint("ck_branch_config_singleton", "\"ConfigId\" = 1");
table.ForeignKey(
name: "FK_branch_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_branch_config_UpdatedBy",
table: "branch_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "branch_config");
}
}
}
@@ -297,6 +297,10 @@ namespace ERPCore.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("PhoneNumber")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -323,6 +327,38 @@ namespace ERPCore.Migrations
b.ToTable("hr_branches", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.BranchConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("integer");
b.Property<bool>("BranchingEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("branch_config", null, t =>
{
t.HasCheckConstraint("ck_branch_config_singleton", "\"ConfigId\" = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
{
b.Property<int>("BrandId")
@@ -890,7 +926,7 @@ namespace ERPCore.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("BranchId")
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("City")
@@ -2943,6 +2979,12 @@ namespace ERPCore.Migrations
PermissionId = 39,
Code = "NAV:procurement.purchase-invoices",
SubNavItemId = 28
},
new
{
PermissionId = 40,
Code = "NAV:settings.branches",
SubNavItemId = 29
});
});
@@ -5394,6 +5436,16 @@ namespace ERPCore.Migrations
Status = "Active"
},
new
{
SubNavItemId = 29,
Code = "settings.branches",
Href = "/dashboard/settings/branches",
Label = "Branches",
NavItemId = 9,
SortOrder = 0,
Status = "Active"
},
new
{
SubNavItemId = 7,
Code = "settings.roles",
@@ -5906,6 +5958,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarehouseId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
@@ -5918,6 +5973,8 @@ namespace ERPCore.Migrations
b.HasKey("WarehouseId");
b.HasIndex("BranchId");
b.HasIndex("Code")
.IsUnique();
@@ -6053,6 +6110,16 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BranchConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
@@ -6188,7 +6255,8 @@ namespace ERPCore.Migrations
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict);
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Department", "Department")
.WithMany()
@@ -7680,6 +7748,17 @@ namespace ERPCore.Migrations
b.Navigation("Quotation");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{
b.Navigation("Lines");
+2
View File
@@ -85,6 +85,8 @@ builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>();
@@ -95,7 +95,7 @@ public sealed class AdjustmentService : IAdjustmentService
}
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default)
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default)
{
var q = _adjustments.Query().AsNoTracking();
@@ -105,6 +105,7 @@ public sealed class AdjustmentService : IAdjustmentService
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(a => a.Warehouse!.BranchId == branchId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct);
@@ -0,0 +1,36 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class BranchAccessService : IBranchAccessService
{
private readonly ICurrentUser _currentUser;
private readonly IRepository<Employee> _employees;
private readonly IBranchConfigService _branchConfig;
public BranchAccessService(ICurrentUser currentUser, IRepository<Employee> employees, IBranchConfigService branchConfig)
{
_currentUser = currentUser;
_employees = employees;
_branchConfig = branchConfig;
}
public async Task EnsureBranchVisibleAsync(int branchId, CancellationToken ct = default)
{
if (!_currentUser.IsAuthenticated || _currentUser.IsAdmin) return;
if (!await _branchConfig.IsBranchingEnabledAsync(ct)) return;
var ownBranchId = await _employees.Query().AsNoTracking()
.Where(e => e.UserId == _currentUser.AuditUserId)
.Select(e => (int?)e.BranchId)
.FirstOrDefaultAsync(ct);
if (ownBranchId is null || ownBranchId != branchId)
throw new NotFoundException("The requested record was not found.");
}
}
@@ -0,0 +1,103 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Branch configuration service over the singleton row seeded by DataSeeder. Also owns
/// resolving the effective branch id for an Employee/Warehouse write — the one place that
/// knows how to fall back to a default branch when branching is switched off.
/// </summary>
public sealed class BranchConfigService : IBranchConfigService
{
private const string DefaultBranchCode = "BR-000";
private const string DefaultBranchName = "Main Branch";
private readonly IRepository<BranchConfig> _config;
private readonly IRepository<Branch> _branches;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public BranchConfigService(IRepository<BranchConfig> config, IRepository<Branch> branches, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_branches = branches;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<BranchConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct)
?? throw new NotFoundException("Branch configuration has not been seeded.");
return new ETagged<BranchConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<BranchConfigDto>> UpdateAsync(
UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(BranchConfig.SingletonId, ct)
?? throw new NotFoundException("Branch configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
config.BranchingEnabled = request.BranchingEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
}
return new ETagged<BranchConfigDto>(Map(config), config.RowVersion);
}
public async Task<bool> IsBranchingEnabledAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct);
// Seeded at startup (DataSeeder) — missing only for a database built some other way,
// in which case the safer default is "on" (today's mandatory behaviour), not silently off.
return config?.BranchingEnabled ?? true;
}
public async Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
{
if (await IsBranchingEnabledAsync(ct))
{
if (requestedBranchId <= 0)
throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
return requestedBranchId;
}
// Branching is off — ignore whatever was posted and use the deployment's one default
// branch, creating it on first use (same placeholder MakeEmployeeBranchRequired/
// AddWarehouseBranch backfill onto for pre-existing rows).
var existingId = await _branches.Query().AsNoTracking()
.Where(b => b.Code == DefaultBranchCode).Select(b => b.BranchId).FirstOrDefaultAsync(ct);
if (existingId != 0) return existingId;
var branch = new Branch { Code = DefaultBranchCode, Name = DefaultBranchName, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
await _branches.AddAsync(branch, ct);
await _uow.SaveChangesAsync(ct);
return branch.BranchId;
}
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.UpdatedAt, c.UpdatedBy);
}
+2 -1
View File
@@ -97,7 +97,7 @@ public sealed class CountService : ICountService
}
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default)
PageQuery query, CountStatus? status, int? warehouseId, int? branchId, CancellationToken ct = default)
{
var q = _counts.Query().AsNoTracking();
@@ -108,6 +108,7 @@ public sealed class CountService : ICountService
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(c => c.Warehouse!.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId)
+21 -11
View File
@@ -40,18 +40,19 @@ public sealed class DashboardService : IDashboardService
_reorder = reorder;
}
public async Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default)
public async Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default)
{
// Sequential, not Task.WhenAll: these repositories share one scoped DbContext,
// which cannot run concurrent operations.
var onHandTotal = await _layers.Query().AsNoTracking().SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
var onHandWarehouses = await _layers.Query().AsNoTracking()
.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
var stockValuationTotal = await _layers.Query().AsNoTracking()
.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m;
var layers = _layers.Query().AsNoTracking();
if (branchId is not null) layers = layers.Where(l => l.Warehouse!.BranchId == branchId);
var onHandTotal = await layers.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
var onHandWarehouses = await layers.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
var stockValuationTotal = await layers.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m;
// EF can't translate constructing WarehouseValuationDto directly inside the GroupBy
// Select — project to an anonymous type first, then materialize into the record.
var stockValuationByWarehouseRaw = await _layers.Query().AsNoTracking()
var stockValuationByWarehouseRaw = await layers
.GroupBy(l => l.WarehouseId)
.Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.OrderByDescending(w => w.Total)
@@ -59,17 +60,26 @@ public sealed class DashboardService : IDashboardService
var stockValuationByWarehouse = stockValuationByWarehouseRaw
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total))
.ToList();
var pendingGrns = await _grns.Query().AsNoTracking().CountAsync(g => g.Status == GrnStatus.Draft, ct);
var grns = _grns.Query().AsNoTracking();
if (branchId is not null) grns = grns.Where(g => g.Warehouse!.BranchId == branchId);
var pendingGrns = await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct);
var counts = _counts.Query().AsNoTracking();
if (branchId is not null) counts = counts.Where(c => c.Warehouse!.BranchId == branchId);
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct);
// PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet
// (docs/BRANCH_ISOLATION_PLAN.md classifies them as needing a new BranchId column,
// not yet added) — these three stay global regardless of the selected branch.
var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
var pendingCounts = await _counts.Query().AsNoTracking()
.CountAsync(c => c.Status == CountStatus.Counted, ct);
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
// PageSize:1 is enough — GetAlertsAsync computes the full alert count before paging.
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, new PageQuery { Page = 1, PageSize = 1 }, ct))
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
.Pagination.TotalItems;
return new DashboardStatsDto(
+4 -1
View File
@@ -73,7 +73,7 @@ public sealed class GrnService : IGrnService
}
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default)
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId, CancellationToken ct = default)
{
var q = _grns.Query().AsNoTracking();
@@ -86,6 +86,9 @@ public sealed class GrnService : IGrnService
if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
// GRN carries no BranchId of its own — it inherits branch scope transitively via its
// Warehouse, same as Sales (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism").
if (branchId is not null) q = q.Where(g => g.Warehouse!.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
+27 -7
View File
@@ -36,7 +36,7 @@ public sealed class BranchService : IBranchService
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(b => b.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(b => new BranchDto(b.BranchId, b.Code, b.Name, b.Address, b.Status, b.CreatedAt, b.UpdatedAt))
.Select(b => new BranchDto(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<BranchDto>.Create(rows, query.Page, query.PageSize, total);
@@ -50,15 +50,12 @@ public sealed class BranchService : IBranchService
public async Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _branches.Query().AnyAsync(b => b.Code.ToLower() == code.ToLower(), ct))
throw new ConflictException($"A branch with code '{code}' already exists.");
var branch = new Branch
{
Code = code,
Code = await GenerateCodeAsync(ct),
Name = request.Name.Trim(),
Address = request.Address?.Trim(),
PhoneNumber = request.PhoneNumber?.Trim(),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
@@ -69,6 +66,28 @@ public sealed class BranchService : IBranchService
return new ETagged<BranchDto>(Map(branch), branch.RowVersion);
}
private const string CodePrefix = "BR-";
/// <summary>
/// Next sequential branch code ("BR-001", "BR-002", ...) — codes are never user-entered.
/// Parses the numeric suffix of existing codes rather than trusting a row count, since a
/// deactivated-not-deleted branch never frees its slot and counts would drift.
/// </summary>
private async Task<string> GenerateCodeAsync(CancellationToken ct)
{
var existingCodes = await _branches.Query().AsNoTracking()
.Where(b => b.Code.StartsWith(CodePrefix))
.Select(b => b.Code)
.ToListAsync(ct);
var nextNumber = existingCodes
.Select(c => int.TryParse(c.AsSpan(CodePrefix.Length), out var n) ? n : 0)
.DefaultIfEmpty(0)
.Max() + 1;
return $"{CodePrefix}{nextNumber:D3}";
}
public async Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var branch = await _branches.GetByIdAsync(branchId, ct)
@@ -79,6 +98,7 @@ public sealed class BranchService : IBranchService
branch.Name = request.Name.Trim();
branch.Address = request.Address?.Trim();
branch.PhoneNumber = request.PhoneNumber?.Trim();
branch.UpdatedAt = DateTime.UtcNow;
try
@@ -103,5 +123,5 @@ public sealed class BranchService : IBranchService
await _uow.SaveChangesAsync(ct);
}
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.Status, b.CreatedAt, b.UpdatedAt);
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt);
}
@@ -21,14 +21,19 @@ public sealed class EmployeeService : IEmployeeService
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly IRepository<EmployeeBankDetail> _bankDetails;
private readonly IBranchConfigService _branchConfig;
private readonly IBranchAccessService _branchAccess;
private readonly IUnitOfWork _uow;
public EmployeeService(
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails, IUnitOfWork uow)
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
{
_employees = employees;
_users = users;
_bankDetails = bankDetails;
_branchConfig = branchConfig;
_branchAccess = branchAccess;
_uow = uow;
}
@@ -66,7 +71,10 @@ public sealed class EmployeeService : IEmployeeService
public async Task<ETagged<EmployeeDetailDto>?> GetAsync(int employeeId, CancellationToken ct = default)
{
var employee = await _employees.Query().AsNoTracking().FirstOrDefaultAsync(e => e.EmployeeId == employeeId, ct);
return employee is null ? null : new ETagged<EmployeeDetailDto>(Map(employee), employee.RowVersion);
if (employee is null) return null;
await _branchAccess.EnsureBranchVisibleAsync(employee.BranchId, ct);
return new ETagged<EmployeeDetailDto>(Map(employee), employee.RowVersion);
}
public async Task<ETagged<EmployeeDetailDto>> CreateAsync(CreateEmployeeRequest request, int actorUserId, CancellationToken ct = default)
@@ -85,6 +93,8 @@ public sealed class EmployeeService : IEmployeeService
linkedUserId = user.UserId;
}
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var employee = new Employee
{
EmployeeCode = code,
@@ -107,7 +117,7 @@ public sealed class EmployeeService : IEmployeeService
DepartmentId = request.DepartmentId,
DesignationId = request.DesignationId,
EmploymentTypeId = request.EmploymentTypeId,
BranchId = request.BranchId,
BranchId = branchId,
WorkShiftId = request.WorkShiftId,
ReportingManagerId = request.ReportingManagerId,
EpfNumber = request.EpfNumber?.Trim(),
@@ -131,6 +141,8 @@ public sealed class EmployeeService : IEmployeeService
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
await _branchAccess.EnsureBranchVisibleAsync(employee.BranchId, ct);
if (employee.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412);
@@ -154,7 +166,7 @@ public sealed class EmployeeService : IEmployeeService
employee.DepartmentId = request.DepartmentId;
employee.DesignationId = request.DesignationId;
employee.EmploymentTypeId = request.EmploymentTypeId;
employee.BranchId = request.BranchId;
employee.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
employee.WorkShiftId = request.WorkShiftId;
employee.ReportingManagerId = request.ReportingManagerId;
employee.EpfNumber = request.EpfNumber?.Trim();
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface IAdjustmentService
{
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default);
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default);
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
@@ -0,0 +1,19 @@
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Server-side branch-scoping guard for detail/update endpoints (docs/BRANCH_ISOLATION_PLAN.md
/// "What this doesn't solve by itself" — the Phase-2 enforcement layer the plan flags as
/// missing). List endpoints filter by an explicit <c>branchId</c> query param the frontend
/// supplies; this instead protects a single record fetched/edited by id, where a non-admin
/// could otherwise guess or type another branch's id directly.
/// </summary>
public interface IBranchAccessService
{
/// <summary>
/// No-op when branching is disabled (docs/BRANCH_ISOLATION_PLAN.md's toggle) or the caller
/// is Admin. Otherwise throws <see cref="ERPCore.System.Errors.NotFoundException"/> — not a
/// 403 — when <paramref name="branchId"/> isn't the caller's own branch, so a record in
/// another branch reads as "doesn't exist" rather than confirming it exists elsewhere.
/// </summary>
Task EnsureBranchVisibleAsync(int branchId, CancellationToken ct = default);
}
@@ -0,0 +1,23 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
namespace ERPCore.Services.Interfaces;
/// <summary>Branch configuration business logic. Singleton.</summary>
public interface IBranchConfigService
{
Task<ETagged<BranchConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<BranchConfigDto>> UpdateAsync(
UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
/// <summary>True when this deployment requires a branch to be picked explicitly.</summary>
Task<bool> IsBranchingEnabledAsync(CancellationToken ct = default);
/// <summary>
/// The branch id an Employee/Warehouse write should actually use: <paramref name="requestedBranchId"/>
/// as-is when branching is enabled (rejecting an unset one), or the deployment's single
/// default branch when branching is disabled — creating that default branch on first use.
/// </summary>
Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
}
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface ICountService
{
Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default);
PageQuery query, CountStatus? status, int? warehouseId, int? branchId, CancellationToken ct = default);
Task<CountDto?> GetAsync(int countId, CancellationToken ct = default);
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
@@ -5,5 +5,5 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
public interface IDashboardService
{
Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default);
Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default);
}
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface IGrnService
{
Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default);
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId, CancellationToken ct = default);
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
@@ -7,6 +7,6 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Reorder alerts and suggested requisitions (docs/11 §5.7; FR-STK-10).</summary>
public interface IReorderService
{
Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, PageQuery query, CancellationToken ct = default);
Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
Task<RequisitionDto> CreateSuggestedRequisitionAsync(int itemId, int warehouseId, CancellationToken ct = default);
}
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService
{
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
@@ -10,10 +10,10 @@ public interface IStockService
/// <summary>On-hand for every (item, warehouse) pair holding stock — backs the enquiry list.</summary>
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default);
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface ITransferService
{
Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default);
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, int? branchId, CancellationToken ct = default);
Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default);
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
@@ -12,7 +12,7 @@ public interface IUserManagementService
{
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default);
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
+2 -1
View File
@@ -26,10 +26,11 @@ public sealed class ReorderService : IReorderService
_requisitions = requisitions;
}
public async Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
{
var q = _reorders.Query().AsNoTracking();
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(r => r.Warehouse!.BranchId == branchId);
var policies = await q.OrderBy(r => r.ItemId).ToListAsync(ct);
var alerts = new List<ReorderAlertDto>();
+26 -4
View File
@@ -4,6 +4,7 @@ using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
@@ -15,20 +16,28 @@ namespace ERPCore.Services;
public sealed class RoleService : IRoleService
{
private const string AdminRoleCode = "Admin";
private readonly IRepository<Role> _roles;
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees;
private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
IAuthHexClient authHex, IUnitOfWork uow)
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
{
_roles = roles;
_rolePermissions = rolePermissions;
_permissions = permissions;
_employees = employees;
_currentUser = currentUser;
_authHex = authHex;
_branchConfig = branchConfig;
_uow = uow;
}
@@ -184,17 +193,30 @@ public sealed class RoleService : IRoleService
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
{
// The caller's branch, via their optional Employee link — independent of role,
// since even an Admin's own "home" branch may be worth surfacing later. Null
// when the logged-in account has no linked Employee (docs/12-BACKEND-HRM.md A.5).
var employee = _currentUser.IsAuthenticated
? await _employees.Query().AsNoTracking()
.Include(e => e.Branch)
.FirstOrDefaultAsync(e => e.UserId == _currentUser.AuditUserId, ct)
: null;
var branchId = employee?.BranchId;
var branchName = employee?.Branch?.Name;
var branchingEnabled = await _branchConfig.IsBranchingEnabledAsync(ct);
if (string.IsNullOrWhiteSpace(roleCode))
return new MeResponseDto(null, null, Array.Empty<string>());
return new MeResponseDto(null, null, Array.Empty<string>(), false, branchId, branchName, branchingEnabled);
// AuthHex mints the RoleCode claim independently of ERPCore's stored casing
// (e.g. token "ADMIN" vs seeded "Admin"), so match case-insensitively — an
// identity code differing only by case must not lock the user out of the nav.
var normalized = roleCode.Trim();
var isAdmin = string.Equals(normalized, AdminRoleCode, StringComparison.OrdinalIgnoreCase);
var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code.ToLower() == normalized.ToLower(), ct);
if (role is null)
return new MeResponseDto(roleCode, null, Array.Empty<string>());
return new MeResponseDto(roleCode, null, Array.Empty<string>(), isAdmin, branchId, branchName, branchingEnabled);
var permissions = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId)
@@ -210,7 +232,7 @@ public sealed class RoleService : IRoleService
.Distinct()
.ToList();
return new MeResponseDto(role.Code, role.Name, navCodes);
return new MeResponseDto(role.Code, role.Name, navCodes, isAdmin, branchId, branchName, branchingEnabled);
}
private static RoleDto Map(Role r) => new(
@@ -49,7 +49,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow;
}
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default)
{
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -60,6 +60,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
// Sales carries no BranchId of its own — it inherits branch scope transitively via
// its Warehouse (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism: Warehouse carries the branch").
if (branchId is not null) q = q.Where(x => x.Warehouse!.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
+20 -3
View File
@@ -14,17 +14,30 @@ public sealed class StockService : IStockService
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<StockTransferLine> _transferLines;
private readonly IRepository<Warehouse> _warehouses;
public StockService(
IFifoCostingService fifo, IRepository<StockLayer> layers,
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines)
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines, IRepository<Warehouse> warehouses)
{
_fifo = fifo;
_layers = layers;
_ledger = ledger;
_transferLines = transferLines;
_warehouses = warehouses;
}
/// <summary>
/// Ids of every warehouse in <paramref name="branchId"/>'s branch, or null when no branch
/// filter applies. Stock entities carry only a raw <c>WarehouseId</c> (no navigation
/// property, by design — see <c>StockLedger</c>'s doc comment), so branch-scoping goes
/// through this id list rather than a LINQ join on a nav property.
/// </summary>
private async Task<List<int>?> ResolveBranchWarehouseIdsAsync(int? branchId, CancellationToken ct)
=> branchId is null
? null
: await _warehouses.Query().AsNoTracking().Where(w => w.BranchId == branchId).Select(w => w.WarehouseId).ToListAsync(ct);
public async Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
{
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
@@ -58,11 +71,13 @@ public sealed class StockService : IStockService
/// warehouse simply does not appear.
/// </summary>
public async Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default)
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
{
var layers = _layers.Query().AsNoTracking();
if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId);
if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId);
var branchWarehouseIds = await ResolveBranchWarehouseIdsAsync(branchId, ct);
if (branchWarehouseIds is not null) layers = layers.Where(l => branchWarehouseIds.Contains(l.WarehouseId));
var grouped = layers
.GroupBy(l => new { l.ItemId, l.WarehouseId })
@@ -116,12 +131,14 @@ public sealed class StockService : IStockService
}
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
{
var q = _ledger.Query().AsNoTracking();
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
var branchWarehouseIds = await ResolveBranchWarehouseIdsAsync(branchId, ct);
if (branchWarehouseIds is not null) q = q.Where(l => branchWarehouseIds.Contains(l.WarehouseId));
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
// Source-doc filter: the ledger references its originating document polymorphically
+4 -1
View File
@@ -50,7 +50,7 @@ public sealed class TransferService : ITransferService
}
public async Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default)
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, int? branchId, CancellationToken ct = default)
{
var q = _transfers.Query().AsNoTracking();
@@ -62,6 +62,9 @@ public sealed class TransferService : ITransferService
if (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId);
// A transfer touches two branches when it crosses one — show it on either side
// rather than only the branch that happens to hold the source warehouse.
if (branchId is not null) q = q.Where(t => t.SrcWarehouse!.BranchId == branchId || t.DestWarehouse!.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId)
@@ -2,6 +2,7 @@ using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW;
@@ -16,20 +17,24 @@ public sealed class UserManagementService : IUserManagementService
{
private readonly IRepository<User> _users;
private readonly IRepository<Role> _roles;
private readonly IRepository<Employee> _employees;
private readonly IAuthUserService _authUsers;
private readonly IAuthHexClient _authHex;
private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeService _employeeService;
private readonly IUnitOfWork _uow;
public UserManagementService(
IRepository<User> users, IRepository<Role> roles, IAuthUserService authUsers, IAuthHexClient authHex,
IEmployeeUserLinkService links, IUnitOfWork uow)
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees, IAuthUserService authUsers,
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
{
_users = users;
_roles = roles;
_employees = employees;
_authUsers = authUsers;
_authHex = authHex;
_links = links;
_employeeService = employeeService;
_uow = uow;
}
@@ -57,11 +62,30 @@ public sealed class UserManagementService : IUserManagementService
return user is null ? null : Map(user);
}
public async Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default)
public async Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default)
{
var role = await _roles.GetByIdAsync(request.RoleId, ct)
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
// Non-admin roles are branch-scoped (BranchAccessService.EnsureBranchVisibleAsync
// resolves the caller's branch via their linked Employee) — either link an existing,
// already branch-scoped Employee, or supply enough to create a new one.
var isAdminRole = string.Equals(role.Code, "Admin", StringComparison.OrdinalIgnoreCase);
string? employeeCode = null;
if (!isAdminRole && request.LinkEmployeeId is null)
{
if (string.IsNullOrWhiteSpace(request.EmployeeCode) || request.DepartmentId is null
|| request.DesignationId is null || request.EmploymentTypeId is null || request.WorkShiftId is null)
{
throw new DomainException(ErrorCodes.Validation,
"Non-admin users must be assigned to a branch: employeeCode, branchId, departmentId, designationId, employmentTypeId and workShiftId are required.", 422);
}
employeeCode = request.EmployeeCode.Trim();
if (await _employees.Query().AnyAsync(e => e.EmployeeCode.ToLower() == employeeCode.ToLower(), ct))
throw new DomainException(ErrorCodes.EmployeeCodeDuplicate, $"An employee with code '{employeeCode}' already exists.", 400);
}
var username = request.Username.Trim();
if (await _users.Query().AnyAsync(u => u.Username == username, ct))
throw new ConflictException($"A user with username '{username}' already exists.");
@@ -103,7 +127,27 @@ public sealed class UserManagementService : IUserManagementService
// Explicit, human-confirmed link to an existing Staff record (never automatic).
if (request.LinkEmployeeId is not null)
{
await _links.LinkAsync(request.LinkEmployeeId.Value, user.UserId, ct);
}
else if (!isAdminRole)
{
await _employeeService.CreateAsync(new CreateEmployeeRequest
{
EmployeeCode = employeeCode!,
FullName = request.FullName.Trim(),
Email = request.Email.Trim(),
PersonalMobile = request.MobileNumber,
Nic = request.Nic,
HireDate = request.HireDate ?? DateTime.UtcNow.Date,
DepartmentId = request.DepartmentId!.Value,
DesignationId = request.DesignationId!.Value,
EmploymentTypeId = request.EmploymentTypeId!.Value,
BranchId = request.BranchId ?? 0,
WorkShiftId = request.WorkShiftId!.Value,
LinkUserId = user.UserId
}, actorUserId, ct);
}
user.Role = role;
return Map(user);
+16 -7
View File
@@ -13,18 +13,24 @@ public sealed class WarehouseService : IWarehouseService
{
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins;
private readonly IRepository<Branch> _branches;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public WarehouseService(IRepository<Warehouse> warehouses, IRepository<Bin> bins, IUnitOfWork uow)
public WarehouseService(
IRepository<Warehouse> warehouses, IRepository<Bin> bins, IRepository<Branch> branches,
IBranchConfigService branchConfig, IUnitOfWork uow)
{
_warehouses = warehouses;
_bins = bins;
_branches = branches;
_branchConfig = branchConfig;
_uow = uow;
}
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _warehouses.Query().AsNoTracking();
IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
@@ -34,7 +40,7 @@ public sealed class WarehouseService : IWarehouseService
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code)
.Skip(query.Skip).Take(query.PageSize)
.Select(w => new WarehouseDto(w.WarehouseId, w.Code, w.Name))
.Select(w => new WarehouseDto(w.WarehouseId, w.Code, w.Name, w.BranchId, w.Branch != null ? w.Branch.Name : null))
.ToListAsync(ct);
return PagedResponse<WarehouseDto>.Create(rows, query.Page, query.PageSize, total);
@@ -42,9 +48,9 @@ public sealed class WarehouseService : IWarehouseService
public async Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default)
{
var w = await _warehouses.Query().AsNoTracking()
var w = await _warehouses.Query().AsNoTracking().Include(x => x.Branch)
.FirstOrDefaultAsync(x => x.WarehouseId == warehouseId, ct);
return w is null ? null : new WarehouseDto(w.WarehouseId, w.Code, w.Name);
return w is null ? null : new WarehouseDto(w.WarehouseId, w.Code, w.Name, w.BranchId, w.Branch?.Name);
}
public async Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default)
@@ -53,11 +59,14 @@ public sealed class WarehouseService : IWarehouseService
if (await _warehouses.Query().AnyAsync(w => w.Code == code, ct))
throw new ConflictException($"A warehouse with code '{code}' already exists.");
var warehouse = new Warehouse { Code = code, Name = request.Name.Trim() };
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var warehouse = new Warehouse { Code = code, Name = request.Name.Trim(), BranchId = branchId };
await _warehouses.AddAsync(warehouse, ct);
await _uow.SaveChangesAsync(ct);
return new WarehouseDto(warehouse.WarehouseId, warehouse.Code, warehouse.Name);
var branchName = await _branches.Query().AsNoTracking()
.Where(b => b.BranchId == warehouse.BranchId).Select(b => b.Name).FirstOrDefaultAsync(ct);
return new WarehouseDto(warehouse.WarehouseId, warehouse.Code, warehouse.Name, warehouse.BranchId, branchName);
}
public async Task<IReadOnlyList<BinDto>> ListBinsAsync(int warehouseId, CancellationToken ct = default)