Merge pull request 'Branch crud' (#52) from branch-crud into Dev

Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
2026-09-08 07:13:47 +00:00
170 changed files with 54041 additions and 760 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> /// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
[HttpGet("stats")] [HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct) public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(ct)); => Ok(await _dashboard.GetStatsAsync(branchId, ct));
} }
+3 -24
View File
@@ -11,12 +11,10 @@ namespace ERPCore.Controllers;
public sealed class GrnsController : ApiControllerBase public sealed class GrnsController : ApiControllerBase
{ {
private readonly IGrnService _grns; private readonly IGrnService _grns;
private readonly IGrnPaymentService _payments;
public GrnsController(IGrnService grns, IGrnPaymentService payments) public GrnsController(IGrnService grns)
{ {
_grns = grns; _grns = grns;
_payments = payments;
} }
/// <summary>List GRNs, newest first.</summary> /// <summary>List GRNs, newest first.</summary>
@@ -24,8 +22,8 @@ public sealed class GrnsController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List( public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId, [FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct) [FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct)); => Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
[HttpGet("{grnId:int}")] [HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
@@ -64,23 +62,4 @@ public sealed class GrnsController : ApiControllerBase
public async Task<ActionResult<ReleaseLineResultDto>> Release( public async Task<ActionResult<ReleaseLineResultDto>> Release(
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct) int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct)); => Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
/// <summary>Pay the vendor against this GRN's balance, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{grnId:int}/payments")]
[ProducesResponseType(typeof(GrnPaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<GrnPaymentDto>> Pay(int grnId, [FromBody] CreateGrnPaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(grnId, request, ct);
return Created($"/api/v1/grns/{grnId}/payments/{dto.GrnPaymentId}", dto);
}
/// <summary>Payment history for this GRN, newest first.</summary>
[HttpGet("{grnId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<GrnPaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<GrnPaymentDto>>> ListPayments(int grnId, CancellationToken ct)
=> Ok(await _payments.ListAsync(grnId, ct));
} }
@@ -0,0 +1,102 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.</summary>
[Route("api/v1/purchase-invoices")]
public sealed class PurchaseInvoicesController : ApiControllerBase
{
private readonly IPurchaseInvoiceService _invoices;
private readonly IPurchaseInvoicePaymentService _payments;
public PurchaseInvoicesController(IPurchaseInvoiceService invoices, IPurchaseInvoicePaymentService payments)
{
_invoices = invoices;
_payments = payments;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseInvoiceSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseInvoiceSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseInvoiceStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, vendorId, ct));
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
[HttpGet("eligible-grns")]
[ProducesResponseType(typeof(IReadOnlyList<GrnEligibleForInvoiceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<GrnEligibleForInvoiceDto>>> EligibleGrns([FromQuery] int vendorId, CancellationToken ct)
=> Ok(await _invoices.ListEligibleGrnsAsync(vendorId, ct));
[HttpGet("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseInvoiceDto>> GetById(int purchaseInvoiceId, CancellationToken ct)
{
var result = await _invoices.GetAsync(purchaseInvoiceId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PurchaseInvoiceDto>> Create([FromBody] CreatePurchaseInvoiceRequest request, CancellationToken ct)
{
var result = await _invoices.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/purchase-invoices/{result.Value.PurchaseInvoiceId}", result.Value);
}
[HttpPut("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<PurchaseInvoiceDto>> Update(int purchaseInvoiceId, [FromBody] UpdatePurchaseInvoiceRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _invoices.UpdateAsync(purchaseInvoiceId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{purchaseInvoiceId:int}/post")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Post(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.PostAsync(purchaseInvoiceId, ct));
[HttpPost("{purchaseInvoiceId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Cancel(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(purchaseInvoiceId, ct));
/// <summary>Pay the vendor against this invoice's balance, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(PurchaseInvoicePaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoicePaymentDto>> Pay(int purchaseInvoiceId, [FromBody] CreatePurchaseInvoicePaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(purchaseInvoiceId, request, ct);
return Created($"/api/v1/purchase-invoices/{purchaseInvoiceId}/payments/{dto.PurchaseInvoicePaymentId}", dto);
}
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<PurchaseInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<PurchaseInvoicePaymentDto>>> ListPayments(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _payments.ListAsync(purchaseInvoiceId, ct));
}
@@ -25,8 +25,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] SalesInvoiceStatus? status, [FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId, [FromQuery] int? customerId,
[FromQuery] int? warehouseId, [FromQuery] int? warehouseId,
[FromQuery] int? branchId,
CancellationToken ct) CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct)); => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
[HttpGet("{salesInvoiceId:int}")] [HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -17,8 +17,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List( public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct) [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct)); => Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary> /// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")] [HttpGet("{adjustmentId:int}")]
@@ -28,8 +28,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("on-hand/list")] [HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList( public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct) [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct)); => Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, branchId, query, ct));
/// <summary> /// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did /// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
@@ -39,11 +39,11 @@ public sealed class StockController : ApiControllerBase
[HttpGet("ledger")] [HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger( 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] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId, [FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct) [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")] [HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -54,8 +54,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("reorder-alerts")] [HttpGet("reorder-alerts")]
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts( public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
[FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct) [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct)); => Ok(await _reorder.GetAlertsAsync(warehouseId, branchId, query, ct));
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary> /// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
[HttpPost("reorder-alerts/{itemId:int}/requisition")] [HttpPost("reorder-alerts/{itemId:int}/requisition")]
@@ -18,8 +18,8 @@ public sealed class StockCountsController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List( public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct) [FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct)); => Ok(await _counts.ListAsync(query, status, warehouseId, branchId, ct));
[HttpGet("{countId:int}")] [HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
@@ -19,8 +19,8 @@ public sealed class StockTransfersController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List( public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status, [FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct) [FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct)); => Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, branchId, ct));
[HttpGet("{transferId:int}")] [HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
@@ -1,6 +1,7 @@
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users; using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -15,11 +16,13 @@ public sealed class UsersController : ApiControllerBase
{ {
private readonly IUserManagementService _users; private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links; private readonly IEmployeeUserLinkService _links;
private readonly ICurrentUser _currentUser;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links) public UsersController(IUserManagementService users, IEmployeeUserLinkService links, ICurrentUser currentUser)
{ {
_users = users; _users = users;
_links = links; _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> /// <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)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct) 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); return Created($"/api/v1/users/{result.UserId}", result);
} }
+1
View File
@@ -10,6 +10,7 @@ public static class DocumentTypes
public const string Rfq = "RFQ"; public const string Rfq = "RFQ";
public const string PurchaseOrder = "PO"; public const string PurchaseOrder = "PO";
public const string Grn = "GRN"; public const string Grn = "GRN";
public const string PurchaseInvoice = "PINV";
public const string Transfer = "TRF"; public const string Transfer = "TRF";
public const string Adjustment = "ADJ"; public const string Adjustment = "ADJ";
public const string Count = "CNT"; public const string Count = "CNT";
+5 -2
View File
@@ -3,16 +3,19 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced /// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Required by
/// optionally by <see cref="Employee.BranchId"/> and <see cref="PayrollRun.BranchId"/>. /// <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. /// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary> /// </summary>
public class Branch public class Branch
{ {
public int BranchId { get; set; } 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 Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Address { get; set; } public string? Address { get; set; }
public string? PhoneNumber { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active; public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; } 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 Designation? Designation { get; set; }
public int EmploymentTypeId { get; set; } public int EmploymentTypeId { get; set; }
public EmploymentType? EmploymentType { get; set; } public EmploymentType? EmploymentType { get; set; }
public int? BranchId { get; set; } public int BranchId { get; set; }
public Branch? Branch { get; set; } public Branch? Branch { get; set; }
public int WorkShiftId { get; set; } public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; } public WorkShift? WorkShift { get; set; }
+5 -5
View File
@@ -34,14 +34,14 @@ public class Grn
public string? GlJournalNo { get; set; } public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; } public DateTime? GlPostedAt { get; set; }
/// <summary>Sum of vendor payments made against this GRN's total payable (<see cref="GrnLine.LineTotal"/>).</summary> /// <summary>The Purchase Invoice this GRN has been consolidated into, if any (null until
public decimal PaidAmount { get; set; } /// invoiced). Vendor settlement is tracked on the invoice, not per-GRN — see
/// <summary>Total payable minus <see cref="PaidAmount"/>; installments accrue against this until it reaches zero.</summary> /// <see cref="PurchaseInvoice"/>.</summary>
public decimal BalanceAmount { get; set; } public int? PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary> /// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
public uint RowVersion { get; set; } public uint RowVersion { get; set; }
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>(); public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
public ICollection<GrnPayment> Payments { get; set; } = new List<GrnPayment>();
} }
@@ -0,0 +1,50 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Vendor invoice consolidating one or more Confirmed GRNs for the same vendor
/// (each linked GRN becomes one <see cref="PurchaseInvoiceLine"/>, not re-itemized).
/// Draft is editable (GRNs can be re-selected); Posted reclassifies the GRN clearing
/// balance to Accounts Payable (see PurchaseInvoiceService.PostAsync); vendor payments
/// are then recorded against this aggregate rather than against the individual GRNs.
/// </summary>
public class PurchaseInvoice
{
public int PurchaseInvoiceId { get; set; }
public string DocNo { get; set; } = string.Empty;
public string VendorInvoiceNo { get; set; } = string.Empty;
public DateTime VendorInvoiceDate { get; set; }
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public string VendorSnapshotName { get; set; } = string.Empty;
public string? VendorSnapshotTaxNo { get; set; }
public PurchaseInvoiceStatus Status { get; set; } = PurchaseInvoiceStatus.Draft;
public decimal Subtotal { get; set; }
public decimal VatTotal { get; set; }
public decimal GrandTotal { get; set; }
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
/// <summary>Journal number of the AP-recognition GL journal entry posted when this
/// invoice is Posted (Debit GRN Clearing / Credit Accounts Payable) — see
/// PurchaseInvoiceService.PostAsync.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<PurchaseInvoiceLine> Lines { get; set; } = new List<PurchaseInvoiceLine>();
public ICollection<PurchaseInvoicePayment> Payments { get; set; } = new List<PurchaseInvoicePayment>();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One row per consolidated GRN on a <see cref="PurchaseInvoice"/> — a straight
/// consolidation of that GRN's line totals, not a re-itemization (docs: Purchase
/// Invoice consolidates GRNs, it does not re-enter item lines).
/// </summary>
public class PurchaseInvoiceLine
{
public int PurchaseInvoiceLineId { get; set; }
public int PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
public int GrnId { get; set; }
public Grn? Grn { get; set; }
public decimal Subtotal { get; set; }
public decimal VatTotal { get; set; }
public decimal LineTotal { get; set; }
}
@@ -1,16 +1,17 @@
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
/// A vendor payment made against a confirmed GRN's balance (installments allowed — /// A vendor payment made against a Posted purchase invoice's balance (installments
/// see GrnPaymentService.PayAsync). Posts its own real GL journal entry (Debit GRN /// allowed — see PurchaseInvoicePaymentService.PayAsync). Posts its own real GL
/// Clearing / Credit the selected bank-or-cash account) before being recorded here. /// journal entry (Debit Accounts Payable / Credit the selected bank-or-cash account)
/// before being recorded here.
/// </summary> /// </summary>
public class GrnPayment public class PurchaseInvoicePayment
{ {
public int GrnPaymentId { get; set; } public int PurchaseInvoicePaymentId { get; set; }
public int GrnId { get; set; } public int PurchaseInvoiceId { get; set; }
public Grn? Grn { get; set; } public PurchaseInvoice? PurchaseInvoice { get; set; }
public decimal Amount { get; set; } public decimal Amount { get; set; }
public DateTime PaymentDate { get; set; } public DateTime PaymentDate { get; set; }
@@ -4,7 +4,7 @@ namespace ERPCore.Domain.Entities;
/// A customer payment made against a Posted sales invoice's balance (installments /// A customer payment made against a Posted sales invoice's balance (installments
/// allowed — see SalesInvoicePaymentService.PayAsync). Posts its own real GL journal /// allowed — see SalesInvoicePaymentService.PayAsync). Posts its own real GL journal
/// entry (Debit the selected bank-or-cash account / Credit Accounts Receivable) before /// entry (Debit the selected bank-or-cash account / Credit Accounts Receivable) before
/// being recorded here — the mirror image of <see cref="GrnPayment"/>. /// being recorded here — the mirror image of <see cref="PurchaseInvoicePayment"/>.
/// </summary> /// </summary>
public class SalesInvoicePayment public class SalesInvoicePayment
{ {
@@ -9,6 +9,8 @@ public class Warehouse
public int WarehouseId { get; set; } public int WarehouseId { get; set; }
public string Code { get; set; } = string.Empty; public string Code { get; set; } = string.Empty;
public string Name { 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>(); public ICollection<Bin> Bins { get; set; } = new List<Bin>();
} }
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum PurchaseInvoiceStatus
{
Draft,
Posted,
Cancelled
}
@@ -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; }
}
+7 -13
View File
@@ -17,34 +17,28 @@ public sealed record GrnLineDto(
public sealed record GrnDto( public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo, int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
decimal PaidAmount, decimal BalanceAmount, IReadOnlyList<GrnLineDto> Lines); int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary> /// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto( public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
decimal PaidAmount, decimal BalanceAmount); int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto( public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId, int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate); decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
public sealed record GrnConfirmResultDto( public sealed record GrnConfirmResultDto(
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo, decimal BalanceAmount, int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo,
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus); IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus); public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
public sealed record GrnPaymentDto( /// <summary>A Confirmed, not-yet-invoiced GRN eligible to be consolidated onto a Purchase Invoice.</summary>
int GrnPaymentId, int GrnId, decimal Amount, DateTime PaymentDate, public sealed record GrnEligibleForInvoiceDto(
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt); int GrnId, string DocNo, DateTime PostedAt, int WarehouseId,
int LineCount, decimal Subtotal, decimal VatTotal, decimal GrandTotal);
public sealed class CreateGrnPaymentRequest
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
}
// Requests ---------------------------------------------------------------------- // Requests ----------------------------------------------------------------------
+4 -4
View File
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Hrm;
public sealed record EmployeeListItemDto( public sealed record EmployeeListItemDto(
int EmployeeId, string EmployeeCode, string FullName, string? Email, int EmployeeId, string EmployeeCode, string FullName, string? Email,
int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName, 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); EmployeeStatus Status, bool HasUserLink, DateTime HireDate);
public sealed record EmployeeDetailDto( public sealed record EmployeeDetailDto(
@@ -16,7 +16,7 @@ public sealed record EmployeeDetailDto(
string? City, string? PostalCode, string? Country, string? City, string? PostalCode, string? Country,
string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone, string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone,
DateTime HireDate, DateTime? ConfirmationDate, DateTime? LastWorkingDate, 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? ReportingManagerId, string? EpfNumber, string? EtfNumber, string? TaxIdentificationNumber,
int? UserId, EmployeeStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); 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 DepartmentId { get; set; }
[Required] public int DesignationId { get; set; } [Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { 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; } [Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; } public int? ReportingManagerId { get; set; }
@@ -82,7 +82,7 @@ public sealed class UpdateEmployeeRequest
[Required] public int DepartmentId { get; set; } [Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; } [Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { 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; } [Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { 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). // ids, timestamps) excluded from create/update requests (02-SECURITY B.6/C.1).
public sealed record BranchDto( 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); DateTime CreatedAt, DateTime? UpdatedAt);
// Code is server-generated (BranchService.GenerateCodeAsync) — never accepted from the client.
public sealed class CreateBranchRequest public sealed class CreateBranchRequest
{ {
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; } [StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? PhoneNumber { get; set; }
} }
public sealed class UpdateBranchRequest public sealed class UpdateBranchRequest
{ {
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; } [StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? PhoneNumber { get; set; }
} }
public sealed record DepartmentDto( public sealed record DepartmentDto(
@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Procurement;
public sealed record PurchaseInvoiceLineDto(
int PurchaseInvoiceLineId, int GrnId, string GrnDocNo, DateTime GrnPostedAt,
decimal Subtotal, decimal VatTotal, decimal LineTotal);
public sealed record PurchaseInvoiceTotalsDto(
decimal Subtotal, decimal VatTotal, decimal GrandTotal, decimal PaidAmount, decimal BalanceAmount);
public sealed record PurchaseInvoiceDto(
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
int VendorId, string VendorSnapshotName, string? VendorSnapshotTaxNo,
PurchaseInvoiceStatus Status, int GrnCount, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
string? GlJournalNo, DateTime? GlPostedAt,
PurchaseInvoiceTotalsDto Totals, IReadOnlyList<PurchaseInvoiceLineDto> Lines);
public sealed record PurchaseInvoiceSummaryDto(
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
int VendorId, string VendorSnapshotName, PurchaseInvoiceStatus Status, int GrnCount,
PurchaseInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record PurchaseInvoicePaymentDto(
int PurchaseInvoicePaymentId, int PurchaseInvoiceId, decimal Amount, DateTime PaymentDate,
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
public sealed class CreatePurchaseInvoicePaymentRequest
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
}
public sealed class CreatePurchaseInvoiceRequest
{
[Required] public int VendorId { get; set; }
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
[Required] public DateTime VendorInvoiceDate { get; set; }
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
}
public sealed class UpdatePurchaseInvoiceRequest
{
[Required] public int VendorId { get; set; }
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
[Required] public DateTime VendorInvoiceDate { get; set; }
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
}
+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 /// <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 /// for the current user's role and permitted sidebar nav codes (replaces trusting
/// the stale, client-only `roleId` cached in localStorage).</summary> /// the stale, client-only `roleId` cached in localStorage). <see cref="BranchId"/>/
public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes); /// <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). /// email-lookup — never automatic, even on an exact email match (docs/12-BACKEND-HRM.md A.5).
/// </summary> /// </summary>
public int? LinkEmployeeId { get; set; } 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 public sealed class UpdateUserRoleRequest
@@ -2,8 +2,10 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Warehouses; namespace ERPCore.Dtos.Warehouses;
/// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).</summary> /// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5). <see cref="BranchId"/> is
public sealed record WarehouseDto(int WarehouseId, string Code, string Name); /// 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> /// <summary>Bin/location resource (docs/11 §2.5).</summary>
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType); 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(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required] public int BranchId { get; set; }
} }
public sealed class CreateBinRequest 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 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> /// <summary>True when the request carries an authenticated principal.</summary>
bool IsAuthenticated { get; } 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.HasIndex(b => b.Code).IsUnique();
builder.Property(b => b.Name).IsRequired().HasMaxLength(200); builder.Property(b => b.Name).IsRequired().HasMaxLength(200);
builder.Property(b => b.Address).HasMaxLength(500); builder.Property(b => b.Address).HasMaxLength(500);
builder.Property(b => b.PhoneNumber).HasMaxLength(30);
builder.Property(b => b.Status) builder.Property(b => b.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired() .HasConversion<string>().HasMaxLength(20).IsRequired()
@@ -17,41 +17,19 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired(); builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(g => g.CreatedAt).IsRequired(); builder.Property(g => g.CreatedAt).IsRequired();
builder.Property(g => g.GlJournalNo).HasMaxLength(30); builder.Property(g => g.GlJournalNo).HasMaxLength(30);
builder.Property(g => g.PaidAmount).HasPrecision(18, 4);
builder.Property(g => g.BalanceAmount).HasPrecision(18, 4);
builder.Property(g => g.RowVersion).IsRowVersion(); builder.Property(g => g.RowVersion).IsRowVersion();
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict); builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => g.Status); builder.HasIndex(g => g.Status);
builder.HasIndex(g => g.PoId); builder.HasIndex(g => g.PoId);
} }
} }
public sealed class GrnPaymentConfiguration : IEntityTypeConfiguration<GrnPayment>
{
public void Configure(EntityTypeBuilder<GrnPayment> builder)
{
builder.ToTable("grn_payments");
builder.HasKey(p => p.GrnPaymentId);
builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.Grn).WithMany(g => g.Payments).HasForeignKey(p => p.GrnId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.GrnId);
}
}
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine> public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
{ {
public void Configure(EntityTypeBuilder<GrnLine> builder) public void Configure(EntityTypeBuilder<GrnLine> builder)
@@ -63,9 +63,11 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 }, new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 }, new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 },
new Permission { PermissionId = 35, Code = "NAV:production", NavItemId = 14 }, new Permission { PermissionId = 35, Code = "NAV:production", NavItemId = 14 },
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 24 }, new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 27 },
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 }, new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 } new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 },
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 }
); );
} }
} }
@@ -0,0 +1,97 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PurchaseInvoiceConfiguration : IEntityTypeConfiguration<PurchaseInvoice>
{
public void Configure(EntityTypeBuilder<PurchaseInvoice> builder)
{
builder.ToTable("purchase_invoices");
builder.HasKey(x => x.PurchaseInvoiceId);
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(x => x.DocNo).IsUnique();
builder.Property(x => x.VendorInvoiceNo).IsRequired().HasMaxLength(50);
builder.Property(x => x.VendorInvoiceDate).IsRequired();
// A vendor invoice number is only meaningful per-vendor; the service also excludes
// Cancelled invoices from the duplicate check, so this composite index is a coarse
// backstop (it does not distinguish Cancelled rows) rather than the sole enforcement.
builder.HasIndex(x => new { x.VendorId, x.VendorInvoiceNo });
builder.HasOne(x => x.Vendor)
.WithMany()
.HasForeignKey(x => x.VendorId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.VendorSnapshotName).IsRequired().HasMaxLength(200);
builder.Property(x => x.VendorSnapshotTaxNo).HasMaxLength(50);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(PurchaseInvoiceStatus.Draft);
foreach (var p in new[] { nameof(PurchaseInvoice.Subtotal), nameof(PurchaseInvoice.VatTotal), nameof(PurchaseInvoice.GrandTotal), nameof(PurchaseInvoice.PaidAmount), nameof(PurchaseInvoice.BalanceAmount) })
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.VendorId);
builder.HasMany(x => x.Lines)
.WithOne(x => x.PurchaseInvoice)
.HasForeignKey(x => x.PurchaseInvoiceId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public sealed class PurchaseInvoiceLineConfiguration : IEntityTypeConfiguration<PurchaseInvoiceLine>
{
public void Configure(EntityTypeBuilder<PurchaseInvoiceLine> builder)
{
builder.ToTable("purchase_invoice_lines");
builder.HasKey(x => x.PurchaseInvoiceLineId);
builder.Property(x => x.Subtotal).HasPrecision(18, 4);
builder.Property(x => x.VatTotal).HasPrecision(18, 4);
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
builder.HasOne(x => x.Grn)
.WithMany()
.HasForeignKey(x => x.GrnId)
.OnDelete(DeleteBehavior.Restrict);
// A GRN can be consolidated onto at most one active line at a time — cancelling the
// invoice removes its lines and frees the GRN (PurchaseInvoiceService.CancelAsync), so
// this stays enforceable rather than blocking a later re-invoice of the same GRN.
builder.HasIndex(x => x.GrnId).IsUnique();
}
}
public sealed class PurchaseInvoicePaymentConfiguration : IEntityTypeConfiguration<PurchaseInvoicePayment>
{
public void Configure(EntityTypeBuilder<PurchaseInvoicePayment> builder)
{
builder.ToTable("purchase_invoice_payments");
builder.HasKey(p => p.PurchaseInvoicePaymentId);
builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.PurchaseInvoice).WithMany(x => x.Payments).HasForeignKey(p => p.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.PurchaseInvoiceId);
}
}
@@ -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 = 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 = 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 = 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 = 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 = 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 }, new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
@@ -42,6 +43,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 }, new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 }, new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }, new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
new SubNavItem { SubNavItemId = 28, NavItemId = 4, Code = "procurement.purchase-invoices", Label = "Purchase Invoices", Href = "/dashboard/procurement/purchase-invoices", SortOrder = 5 },
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 }, new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 }, new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 }, new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
@@ -56,7 +58,11 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 }, new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }, new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx). // Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
new SubNavItem { SubNavItemId = 24, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 }, // ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
// reused by mistake in migration 20260820100345, which upserts row 24 to become the
// notification-config item and silently overwrites this one. See migration
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 }, new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 } new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
); );
@@ -15,5 +15,9 @@ public sealed class WarehouseConfiguration : IEntityTypeConfiguration<Warehouse>
builder.HasIndex(w => w.Code).IsUnique(); builder.HasIndex(w => w.Code).IsUnique();
builder.Property(w => w.Name).IsRequired().HasMaxLength(200); 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 SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct); // dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct); dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct); dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct); dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct); dirty |= await SeedSalesStockAsync(db, ct);
@@ -110,6 +111,23 @@ public static class DataSeeder
return true; 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) private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{ {
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false; 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> /// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>(); 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> /// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>(); public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>(); public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
@@ -66,7 +68,11 @@ public class ErpDbContext : DbContext
// --- Goods Receipt (docs/10 Part C.3) --- // --- Goods Receipt (docs/10 Part C.3) ---
public DbSet<Grn> Grns => Set<Grn>(); public DbSet<Grn> Grns => Set<Grn>();
public DbSet<GrnLine> GrnLines => Set<GrnLine>(); public DbSet<GrnLine> GrnLines => Set<GrnLine>();
public DbSet<GrnPayment> GrnPayments => Set<GrnPayment>();
// --- Purchase Invoices (vendor invoices consolidating Confirmed GRNs) ---
public DbSet<PurchaseInvoice> PurchaseInvoices => Set<PurchaseInvoice>();
public DbSet<PurchaseInvoiceLine> PurchaseInvoiceLines => Set<PurchaseInvoiceLine>();
public DbSet<PurchaseInvoicePayment> PurchaseInvoicePayments => Set<PurchaseInvoicePayment>();
// --- Batch / Serial (docs/10 Part C.4) --- // --- Batch / Serial (docs/10 Part C.4) ---
public DbSet<Batch> Batches => Set<Batch>(); public DbSet<Batch> Batches => Set<Batch>();
@@ -0,0 +1,24 @@
// <auto-generated />
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260825000000_FixProductionTemplatesSubNavItemIdConflict")]
partial class FixProductionTemplatesSubNavItemIdConflict
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
// This migration only repairs seed row data via raw SQL — no entity/column shape
// changes — so it does not redeclare the full model here. The single source of
// truth for scaffolding future migrations is ErpDbContextModelSnapshot.cs, which
// already reflects this fix (production.templates seeded at SubNavItemId 27).
}
}
}
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <summary>
/// Repairs a collision baked into migration history: 20260818090139_AddProductionNavPermissions
/// inserted "production.templates" as sub_nav_items row 24, then
/// 20260820100345_AddNotificationConfigModulePreferences reused the same literal id 24 for
/// "settings.notification-config" via a raw-SQL <c>INSERT ... ON CONFLICT ("SubNavItemId") DO
/// UPDATE</c>, silently overwriting the production.templates row instead of inserting a new
/// one. Row 24 now correctly holds notification-config; this migration re-creates the lost
/// production.templates row under a fresh id (27, matching the corrected seed data in
/// SubNavItemConfiguration/PermissionConfiguration) and repoints the NAV:production.templates
/// permission (id 36) at it.
/// </summary>
public partial class FixProductionTemplatesSubNavItemIdConflict : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
VALUES (27, 14, 'production.templates', '/dashboard/production/templates', NULL, 'Templates', 1)
ON CONFLICT ("SubNavItemId") DO NOTHING;
""");
migrationBuilder.Sql("""
UPDATE permissions SET "SubNavItemId" = 27 WHERE "PermissionId" = 36;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
UPDATE permissions SET "SubNavItemId" = 24 WHERE "PermissionId" = 36;
""");
migrationBuilder.Sql("""
DELETE FROM sub_nav_items WHERE "SubNavItemId" = 27;
""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,303 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseInvoices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_payments");
migrationBuilder.DropColumn(
name: "BalanceAmount",
table: "grns");
migrationBuilder.DropColumn(
name: "PaidAmount",
table: "grns");
migrationBuilder.AddColumn<int>(
name: "PurchaseInvoiceId",
table: "grns",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "purchase_invoices",
columns: table => new
{
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
VendorInvoiceNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
VendorInvoiceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
VendorId = table.Column<int>(type: "integer", nullable: false),
VendorSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
VendorSnapshotTaxNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
VatTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaidAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BalanceAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
GlPostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoices", x => x.PurchaseInvoiceId);
table.ForeignKey(
name: "FK_purchase_invoices_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoices_vendors_VendorId",
column: x => x.VendorId,
principalTable: "vendors",
principalColumn: "VendorId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "purchase_invoice_lines",
columns: table => new
{
PurchaseInvoiceLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false),
GrnId = table.Column<int>(type: "integer", nullable: false),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
VatTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoice_lines", x => x.PurchaseInvoiceLineId);
table.ForeignKey(
name: "FK_purchase_invoice_lines_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoice_lines_purchase_invoices_PurchaseInvoiceId",
column: x => x.PurchaseInvoiceId,
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "purchase_invoice_payments",
columns: table => new
{
PurchaseInvoicePaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoice_payments", x => x.PurchaseInvoicePaymentId);
table.ForeignKey(
name: "FK_purchase_invoice_payments_purchase_invoices_PurchaseInvoice~",
column: x => x.PurchaseInvoiceId,
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoice_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 28, "procurement.purchase-invoices", "/dashboard/procurement/purchase-invoices", null, "Purchase Invoices", 4, 5 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 39, "NAV:procurement.purchase-invoices", null, 28 });
migrationBuilder.CreateIndex(
name: "IX_grns_PurchaseInvoiceId",
table: "grns",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_lines_GrnId",
table: "purchase_invoice_lines",
column: "GrnId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_lines_PurchaseInvoiceId",
table: "purchase_invoice_lines",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_payments_CreatedBy",
table: "purchase_invoice_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_payments_PurchaseInvoiceId",
table: "purchase_invoice_payments",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_CreatedBy",
table: "purchase_invoices",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_DocNo",
table: "purchase_invoices",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_Status",
table: "purchase_invoices",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_VendorId",
table: "purchase_invoices",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_VendorId_VendorInvoiceNo",
table: "purchase_invoices",
columns: new[] { "VendorId", "VendorInvoiceNo" });
migrationBuilder.AddForeignKey(
name: "FK_grns_purchase_invoices_PurchaseInvoiceId",
table: "grns",
column: "PurchaseInvoiceId",
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_grns_purchase_invoices_PurchaseInvoiceId",
table: "grns");
migrationBuilder.DropTable(
name: "purchase_invoice_lines");
migrationBuilder.DropTable(
name: "purchase_invoice_payments");
migrationBuilder.DropTable(
name: "purchase_invoices");
migrationBuilder.DropIndex(
name: "IX_grns_PurchaseInvoiceId",
table: "grns");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 39);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 28);
migrationBuilder.DropColumn(
name: "PurchaseInvoiceId",
table: "grns");
migrationBuilder.AddColumn<decimal>(
name: "BalanceAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PaidAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "grn_payments",
columns: table => new
{
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
GrnId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
table.ForeignKey(
name: "FK_grn_payments_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_grn_payments_CreatedBy",
table: "grn_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_grn_payments_GrnId",
table: "grn_payments",
column: "GrnId");
}
}
}
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) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("PhoneNumber")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
@@ -323,6 +327,38 @@ namespace ERPCore.Migrations
b.ToTable("hr_branches", (string)null); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
{ {
b.Property<int>("BrandId") b.Property<int>("BrandId")
@@ -890,7 +926,7 @@ namespace ERPCore.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<int?>("BranchId") b.Property<int>("BranchId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("City") b.Property<string>("City")
@@ -1411,10 +1447,6 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<decimal>("BalanceAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -1433,16 +1465,15 @@ namespace ERPCore.Migrations
b.Property<DateTime?>("GlPostedAt") b.Property<DateTime?>("GlPostedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<decimal>("PaidAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int?>("PoId") b.Property<int?>("PoId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<DateTime?>("PostedAt") b.Property<DateTime?>("PostedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<int?>("PurchaseInvoiceId")
.HasColumnType("integer");
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
@@ -1469,6 +1500,8 @@ namespace ERPCore.Migrations
b.HasIndex("PoId"); b.HasIndex("PoId");
b.HasIndex("PurchaseInvoiceId");
b.HasIndex("Status"); b.HasIndex("Status");
b.HasIndex("VendorId"); b.HasIndex("VendorId");
@@ -1584,55 +1617,6 @@ namespace ERPCore.Migrations
b.ToTable("grn_line_warranty_numbers", (string)null); b.ToTable("grn_line_warranty_numbers", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
{
b.Property<int>("GrnPaymentId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnPaymentId"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("BankAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<long>("GlBankAccountId")
.HasColumnType("bigint");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int>("GrnId")
.HasColumnType("integer");
b.Property<DateTime>("PaymentDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Reference")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("GrnPaymentId");
b.HasIndex("CreatedBy");
b.HasIndex("GrnId");
b.ToTable("grn_payments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b => modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b =>
{ {
b.Property<int>("HrDocumentTypeId") b.Property<int>("HrDocumentTypeId")
@@ -2976,7 +2960,7 @@ namespace ERPCore.Migrations
{ {
PermissionId = 36, PermissionId = 36,
Code = "NAV:production.templates", Code = "NAV:production.templates",
SubNavItemId = 24 SubNavItemId = 27
}, },
new new
{ {
@@ -2989,6 +2973,18 @@ namespace ERPCore.Migrations
PermissionId = 38, PermissionId = 38,
Code = "NAV:production.produced", Code = "NAV:production.produced",
SubNavItemId = 26 SubNavItemId = 26
},
new
{
PermissionId = 39,
Code = "NAV:procurement.purchase-invoices",
SubNavItemId = 28
},
new
{
PermissionId = 40,
Code = "NAV:settings.branches",
SubNavItemId = 29
}); });
}); });
@@ -3228,6 +3224,189 @@ namespace ERPCore.Migrations
b.ToTable("production_templates", (string)null); b.ToTable("production_templates", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
{
b.Property<int>("PurchaseInvoiceId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoiceId"));
b.Property<decimal>("BalanceAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTime?>("GlPostedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("GrandTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("PaidAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Draft");
b.Property<decimal>("Subtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("VatTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("VendorId")
.HasColumnType("integer");
b.Property<DateTime>("VendorInvoiceDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("VendorInvoiceNo")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("VendorSnapshotName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("VendorSnapshotTaxNo")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("PurchaseInvoiceId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("Status");
b.HasIndex("VendorId");
b.HasIndex("VendorId", "VendorInvoiceNo");
b.ToTable("purchase_invoices", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b =>
{
b.Property<int>("PurchaseInvoiceLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoiceLineId"));
b.Property<int>("GrnId")
.HasColumnType("integer");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("PurchaseInvoiceId")
.HasColumnType("integer");
b.Property<decimal>("Subtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("VatTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.HasKey("PurchaseInvoiceLineId");
b.HasIndex("GrnId")
.IsUnique();
b.HasIndex("PurchaseInvoiceId");
b.ToTable("purchase_invoice_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b =>
{
b.Property<int>("PurchaseInvoicePaymentId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoicePaymentId"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("BankAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<long>("GlBankAccountId")
.HasColumnType("bigint");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTime>("PaymentDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("PurchaseInvoiceId")
.HasColumnType("integer");
b.Property<string>("Reference")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("PurchaseInvoicePaymentId");
b.HasIndex("CreatedBy");
b.HasIndex("PurchaseInvoiceId");
b.ToTable("purchase_invoice_payments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{ {
b.Property<int>("PoId") b.Property<int>("PoId")
@@ -5257,6 +5436,16 @@ namespace ERPCore.Migrations
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 29,
Code = "settings.branches",
Href = "/dashboard/settings/branches",
Label = "Branches",
NavItemId = 9,
SortOrder = 0,
Status = "Active"
},
new
{ {
SubNavItemId = 7, SubNavItemId = 7,
Code = "settings.roles", Code = "settings.roles",
@@ -5337,6 +5526,16 @@ namespace ERPCore.Migrations
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 28,
Code = "procurement.purchase-invoices",
Href = "/dashboard/procurement/purchase-invoices",
Label = "Purchase Invoices",
NavItemId = 4,
SortOrder = 5,
Status = "Active"
},
new
{ {
SubNavItemId = 9, SubNavItemId = 9,
Code = "ledgers.trial-balance", Code = "ledgers.trial-balance",
@@ -5438,7 +5637,7 @@ namespace ERPCore.Migrations
}, },
new new
{ {
SubNavItemId = 24, SubNavItemId = 27,
Code = "production.templates", Code = "production.templates",
Href = "/dashboard/production/templates", Href = "/dashboard/production/templates",
Label = "Templates", Label = "Templates",
@@ -5759,6 +5958,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarehouseId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarehouseId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("Code") b.Property<string>("Code")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
@@ -5771,6 +5973,8 @@ namespace ERPCore.Migrations
b.HasKey("WarehouseId"); b.HasKey("WarehouseId");
b.HasIndex("BranchId");
b.HasIndex("Code") b.HasIndex("Code")
.IsUnique(); .IsUnique();
@@ -5906,6 +6110,16 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate") b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate")
@@ -6041,7 +6255,8 @@ namespace ERPCore.Migrations
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany() .WithMany()
.HasForeignKey("BranchId") .HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Department", "Department") b.HasOne("ERPCore.Domain.Entities.Department", "Department")
.WithMany() .WithMany()
@@ -6176,6 +6391,11 @@ namespace ERPCore.Migrations
.HasForeignKey("PoId") .HasForeignKey("PoId")
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
.WithMany()
.HasForeignKey("PurchaseInvoiceId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
.WithMany() .WithMany()
.HasForeignKey("VendorId") .HasForeignKey("VendorId")
@@ -6190,6 +6410,8 @@ namespace ERPCore.Migrations
b.Navigation("Creator"); b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
b.Navigation("PurchaseOrder"); b.Navigation("PurchaseOrder");
b.Navigation("Vendor"); b.Navigation("Vendor");
@@ -6248,25 +6470,6 @@ namespace ERPCore.Migrations
b.Navigation("GrnLine"); b.Navigation("GrnLine");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
.WithMany("Payments")
.HasForeignKey("GrnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Grn");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
@@ -6567,6 +6770,63 @@ namespace ERPCore.Migrations
b.Navigation("Creator"); b.Navigation("Creator");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Vendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
.WithMany()
.HasForeignKey("GrnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
.WithMany("Lines")
.HasForeignKey("PurchaseInvoiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Grn");
b.Navigation("PurchaseInvoice");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
.WithMany("Payments")
.HasForeignKey("PurchaseInvoiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.User", "Creator") b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -7488,6 +7748,17 @@ namespace ERPCore.Migrations
b.Navigation("Quotation"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
@@ -7516,8 +7787,6 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
b.Navigation("Payments");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
@@ -7568,6 +7837,13 @@ namespace ERPCore.Migrations
b.Navigation("Stages"); b.Navigation("Stages");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
{
b.Navigation("Lines");
b.Navigation("Payments");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
+6 -1
View File
@@ -85,6 +85,8 @@ builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>(); builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>(); //builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>(); builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>(); builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>(); builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>(); builder.Services.AddScoped<IVendorService, VendorService>();
@@ -105,7 +107,10 @@ builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>(); builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
builder.Services.AddScoped<IStockService, StockService>(); builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>(); builder.Services.AddScoped<IGrnService, GrnService>();
builder.Services.AddScoped<IGrnPaymentService, GrnPaymentService>();
// Purchase invoices (vendor invoices consolidating Confirmed GRNs)
builder.Services.AddScoped<IPurchaseInvoiceService, PurchaseInvoiceService>();
builder.Services.AddScoped<IPurchaseInvoicePaymentService, PurchaseInvoicePaymentService>();
// Sales (Phase 1) // Sales (Phase 1)
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>(); builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
@@ -95,7 +95,7 @@ public sealed class AdjustmentService : IAdjustmentService
} }
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync( 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(); var q = _adjustments.Query().AsNoTracking();
@@ -105,6 +105,7 @@ public sealed class AdjustmentService : IAdjustmentService
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%")); q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
} }
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId); 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); if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct); 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( 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(); 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 (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId); 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 total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId) var rows = await q.OrderByDescending(c => c.CountId)
+21 -11
View File
@@ -40,18 +40,19 @@ public sealed class DashboardService : IDashboardService
_reorder = reorder; _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, // Sequential, not Task.WhenAll: these repositories share one scoped DbContext,
// which cannot run concurrent operations. // which cannot run concurrent operations.
var onHandTotal = await _layers.Query().AsNoTracking().SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; var layers = _layers.Query().AsNoTracking();
var onHandWarehouses = await _layers.Query().AsNoTracking() if (branchId is not null) layers = layers.Where(l => l.Warehouse!.BranchId == branchId);
.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
var stockValuationTotal = await _layers.Query().AsNoTracking() var onHandTotal = await layers.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), 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 // EF can't translate constructing WarehouseValuationDto directly inside the GroupBy
// Select — project to an anonymous type first, then materialize into the record. // 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) .GroupBy(l => l.WarehouseId)
.Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) }) .Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.OrderByDescending(w => w.Total) .OrderByDescending(w => w.Total)
@@ -59,17 +60,26 @@ public sealed class DashboardService : IDashboardService
var stockValuationByWarehouse = stockValuationByWarehouseRaw var stockValuationByWarehouse = stockValuationByWarehouseRaw
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total)) .Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total))
.ToList(); .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() var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct); .CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking() var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct); .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); 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. // 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; .Pagination.TotalItems;
return new DashboardStatsDto( return new DashboardStatsDto(
@@ -1,120 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Vendor payments against a confirmed GRN. Multiple installments are allowed until
/// <see cref="Grn.BalanceAmount"/> reaches zero. Each payment posts its own real GL
/// journal entry (Debit GRN Clearing / Credit the selected bank-or-cash account) before
/// being recorded, using the same call-GL-before-commit pattern as <see cref="GrnService.ConfirmAsync"/>
/// so a rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class GrnPaymentService : IGrnPaymentService
{
private readonly IRepository<Grn> _grns;
private readonly IRepository<GrnPayment> _payments;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glClearingAccountCode;
public GrnPaymentService(
IRepository<Grn> grns, IRepository<GrnPayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_grns = grns;
_payments = payments;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
}
public async Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default)
{
var grn = await _grns.Query().FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found.");
if (grn.Status == GrnStatus.Draft)
throw new DomainException(ErrorCodes.GrnNotPayable, $"GRN {grnId} must be confirmed before it can be paid.", 409);
if (request.Amount > grn.BalanceAmount)
throw new DomainException(ErrorCodes.GrnPaymentExceedsBalance,
$"Payment amount {request.Amount} exceeds the remaining balance {grn.BalanceAmount}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
?? throw new DomainException(ErrorCodes.GrnBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
// Same atomicity approach as GrnService.ConfirmAsync: post to GL first, inside
// this transaction, before anything is committed — a GL rejection/timeout rolls
// the whole payment back with no partial local state.
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "GRN_PAYMENT",
Reference = grn.DocNo,
Narration = $"Payment against GRN {grn.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glClearingAccountCode, request.Amount, 0m, $"Payment against GRN {grn.DocNo}"),
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against GRN {grn.DocNo}")
]
}, token);
var entity = new GrnPayment
{
GrnId = grn.GrnId,
Amount = request.Amount,
PaymentDate = now,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
};
await _payments.AddAsync(entity, token);
grn.PaidAmount += request.Amount;
grn.BalanceAmount -= request.Amount;
return entity;
}, ct);
return Map(payment);
}
public async Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default)
{
if (!await _grns.Query().AnyAsync(g => g.GrnId == grnId, ct))
throw new NotFoundException($"GRN {grnId} was not found.");
return await _payments.Query().AsNoTracking()
.Where(p => p.GrnId == grnId)
.OrderByDescending(p => p.GrnPaymentId)
.Select(p => new GrnPaymentDto(
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
.ToListAsync(ct);
}
private static GrnPaymentDto Map(GrnPayment p) => new(
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
}
+10 -7
View File
@@ -73,7 +73,7 @@ public sealed class GrnService : IGrnService
} }
public async Task<PagedResponse<GrnSummaryDto>> ListAsync( 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(); var q = _grns.Query().AsNoTracking();
@@ -86,13 +86,17 @@ public sealed class GrnService : IGrnService
if (poId is not null) q = q.Where(g => g.PoId == poId); 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 (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId); 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 total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId) var rows = await q.OrderByDescending(g => g.GrnId)
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto( .Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.PaidAmount, g.BalanceAmount)) g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct); .ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -102,6 +106,7 @@ public sealed class GrnService : IGrnService
{ {
var grn = await _grns.Query().AsNoTracking() var grn = await _grns.Query().AsNoTracking()
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers) .Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
.Include(g => g.PurchaseInvoice)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct); .FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
return grn is null ? null : Map(grn); return grn is null ? null : Map(grn);
} }
@@ -302,15 +307,13 @@ public sealed class GrnService : IGrnService
grn.PostedAt = now; grn.PostedAt = now;
grn.GlJournalNo = posted.JournalNo; grn.GlJournalNo = posted.JournalNo;
grn.GlPostedAt = now; grn.GlPostedAt = now;
grn.PaidAmount = 0m;
grn.BalanceAmount = totalPayable;
await UpdatePoStatusAsync(grn.PoId, token); await UpdatePoStatusAsync(grn.PoId, token);
return 0; return 0;
}, ct); }, ct);
return new GrnConfirmResultDto( return new GrnConfirmResultDto(
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount, grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty,
createdLayers.Select(ToCreatedLayer).ToList(), createdLayers.Select(ToCreatedLayer).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList(), ledgerRefs.Select(l => l.LedgerId).ToList(),
await GetPoStatusAsync(grn.PoId, ct)); await GetPoStatusAsync(grn.PoId, ct));
@@ -437,7 +440,7 @@ public sealed class GrnService : IGrnService
.Select(l => l.LedgerId).ToListAsync(ct); .Select(l => l.LedgerId).ToListAsync(ct);
return new GrnConfirmResultDto( return new GrnConfirmResultDto(
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount, grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty,
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct)); layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
} }
@@ -446,7 +449,7 @@ public sealed class GrnService : IGrnService
private static GrnDto Map(Grn g) => new( private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt, g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GlJournalNo, g.PaidAmount, g.BalanceAmount, g.GlJournalNo, g.PurchaseInvoiceId, g.PurchaseInvoice?.DocNo,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto( g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice, l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal, l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
+27 -7
View File
@@ -36,7 +36,7 @@ public sealed class BranchService : IBranchService
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderBy(b => b.Name) var rows = await q.OrderBy(b => b.Name)
.Skip(query.Skip).Take(query.PageSize) .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); .ToListAsync(ct);
return PagedResponse<BranchDto>.Create(rows, query.Page, query.PageSize, total); 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) 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 var branch = new Branch
{ {
Code = code, Code = await GenerateCodeAsync(ct),
Name = request.Name.Trim(), Name = request.Name.Trim(),
Address = request.Address?.Trim(), Address = request.Address?.Trim(),
PhoneNumber = request.PhoneNumber?.Trim(),
Status = EntityStatus.Active, Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
@@ -69,6 +66,28 @@ public sealed class BranchService : IBranchService
return new ETagged<BranchDto>(Map(branch), branch.RowVersion); 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) public async Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default)
{ {
var branch = await _branches.GetByIdAsync(branchId, ct) var branch = await _branches.GetByIdAsync(branchId, ct)
@@ -79,6 +98,7 @@ public sealed class BranchService : IBranchService
branch.Name = request.Name.Trim(); branch.Name = request.Name.Trim();
branch.Address = request.Address?.Trim(); branch.Address = request.Address?.Trim();
branch.PhoneNumber = request.PhoneNumber?.Trim();
branch.UpdatedAt = DateTime.UtcNow; branch.UpdatedAt = DateTime.UtcNow;
try try
@@ -103,5 +123,5 @@ public sealed class BranchService : IBranchService
await _uow.SaveChangesAsync(ct); 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<Employee> _employees;
private readonly IRepository<User> _users; private readonly IRepository<User> _users;
private readonly IRepository<EmployeeBankDetail> _bankDetails; private readonly IRepository<EmployeeBankDetail> _bankDetails;
private readonly IBranchConfigService _branchConfig;
private readonly IBranchAccessService _branchAccess;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public EmployeeService( 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; _employees = employees;
_users = users; _users = users;
_bankDetails = bankDetails; _bankDetails = bankDetails;
_branchConfig = branchConfig;
_branchAccess = branchAccess;
_uow = uow; _uow = uow;
} }
@@ -66,7 +71,10 @@ public sealed class EmployeeService : IEmployeeService
public async Task<ETagged<EmployeeDetailDto>?> GetAsync(int employeeId, CancellationToken ct = default) public async Task<ETagged<EmployeeDetailDto>?> GetAsync(int employeeId, CancellationToken ct = default)
{ {
var employee = await _employees.Query().AsNoTracking().FirstOrDefaultAsync(e => e.EmployeeId == employeeId, ct); 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) 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; linkedUserId = user.UserId;
} }
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var employee = new Employee var employee = new Employee
{ {
EmployeeCode = code, EmployeeCode = code,
@@ -107,7 +117,7 @@ public sealed class EmployeeService : IEmployeeService
DepartmentId = request.DepartmentId, DepartmentId = request.DepartmentId,
DesignationId = request.DesignationId, DesignationId = request.DesignationId,
EmploymentTypeId = request.EmploymentTypeId, EmploymentTypeId = request.EmploymentTypeId,
BranchId = request.BranchId, BranchId = branchId,
WorkShiftId = request.WorkShiftId, WorkShiftId = request.WorkShiftId,
ReportingManagerId = request.ReportingManagerId, ReportingManagerId = request.ReportingManagerId,
EpfNumber = request.EpfNumber?.Trim(), EpfNumber = request.EpfNumber?.Trim(),
@@ -131,6 +141,8 @@ public sealed class EmployeeService : IEmployeeService
var employee = await _employees.GetByIdAsync(employeeId, ct) var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found."); ?? throw new NotFoundException($"Employee {employeeId} was not found.");
await _branchAccess.EnsureBranchVisibleAsync(employee.BranchId, ct);
if (employee.RowVersion != expectedRowVersion) if (employee.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412); 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.DepartmentId = request.DepartmentId;
employee.DesignationId = request.DesignationId; employee.DesignationId = request.DesignationId;
employee.EmploymentTypeId = request.EmploymentTypeId; employee.EmploymentTypeId = request.EmploymentTypeId;
employee.BranchId = request.BranchId; employee.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
employee.WorkShiftId = request.WorkShiftId; employee.WorkShiftId = request.WorkShiftId;
employee.ReportingManagerId = request.ReportingManagerId; employee.ReportingManagerId = request.ReportingManagerId;
employee.EpfNumber = request.EpfNumber?.Trim(); employee.EpfNumber = request.EpfNumber?.Trim();
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface IAdjustmentService public interface IAdjustmentService
{ {
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync( 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); 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 public interface ICountService
{ {
Task<PagedResponse<CountSummaryDto>> ListAsync( 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?> GetAsync(int countId, CancellationToken ct = default);
Task<CountDto> CreateAsync(CreateCountRequest request, 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> /// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
public interface IDashboardService public interface IDashboardService
{ {
Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default); Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default);
} }
@@ -1,10 +0,0 @@
using ERPCore.Dtos.Grn;
namespace ERPCore.Services.Interfaces;
/// <summary>Vendor payments against a confirmed GRN's balance (installments allowed).</summary>
public interface IGrnPaymentService
{
Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default);
}
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface IGrnService public interface IGrnService
{ {
Task<PagedResponse<GrnSummaryDto>> ListAsync( 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?> GetAsync(int grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default); Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
@@ -0,0 +1,10 @@
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
/// <summary>Vendor payments against a Posted purchase invoice's balance (installments allowed).</summary>
public interface IPurchaseInvoicePaymentService
{
Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
public interface IPurchaseInvoiceService
{
Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default);
Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default);
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default);
}
@@ -7,6 +7,6 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Reorder alerts and suggested requisitions (docs/11 §5.7; FR-STK-10).</summary> /// <summary>Reorder alerts and suggested requisitions (docs/11 §5.7; FR-STK-10).</summary>
public interface IReorderService 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); Task<RequisitionDto> CreateSuggestedRequisitionAsync(int itemId, int warehouseId, CancellationToken ct = default);
} }
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService 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<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, 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> /// <summary>On-hand for every (item, warehouse) pair holding stock — backs the enquiry list.</summary>
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync( 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( 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); string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default); Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface ITransferService public interface ITransferService
{ {
Task<PagedResponse<TransferSummaryDto>> ListAsync( 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?> GetAsync(int transferId, CancellationToken ct = default);
Task<TransferDto> CreateAsync(CreateTransferRequest request, 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<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<ManagedUserDto?> GetAsync(int userId, 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); Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary> /// <summary>AuthHex UserType options for the create-user form's select.</summary>
@@ -0,0 +1,119 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Vendor payments against a Posted purchase invoice. Multiple installments are allowed
/// until <see cref="PurchaseInvoice.BalanceAmount"/> reaches zero. Each payment posts its
/// own real GL journal entry (Debit Accounts Payable / Credit the selected bank-or-cash
/// account) — the mirror image of <see cref="SalesInvoicePaymentService"/> (that one
/// credits Accounts Receivable when the customer pays; this one debits Accounts Payable
/// when the vendor is paid) — using the same call-GL-before-commit pattern so a
/// rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentService
{
private readonly IRepository<PurchaseInvoice> _invoices;
private readonly IRepository<PurchaseInvoicePayment> _payments;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccountsPayableCode;
public PurchaseInvoicePaymentService(
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_invoices = invoices;
_payments = payments;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
}
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default)
{
var invoice = await _invoices.Query().FirstOrDefaultAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Posted)
throw new DomainException(ErrorCodes.PurchaseInvoiceNotPayable, $"Purchase invoice {purchaseInvoiceId} must be posted before it can be paid.", 409);
if (request.Amount > invoice.BalanceAmount)
throw new DomainException(ErrorCodes.PurchaseInvoicePaymentExceedsBalance,
$"Payment amount {request.Amount} exceeds the remaining balance {invoice.BalanceAmount}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
?? throw new DomainException(ErrorCodes.PurchaseInvoiceBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "PURCHASE_INVOICE_PAYMENT",
Reference = invoice.DocNo,
Narration = $"Payment against invoice {invoice.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glAccountsPayableCode, request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}")
]
}, token);
var entity = new PurchaseInvoicePayment
{
PurchaseInvoiceId = invoice.PurchaseInvoiceId,
Amount = request.Amount,
PaymentDate = now,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
};
await _payments.AddAsync(entity, token);
invoice.PaidAmount += request.Amount;
invoice.BalanceAmount -= request.Amount;
return entity;
}, ct);
return Map(payment);
}
public async Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct))
throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
return await _payments.Query().AsNoTracking()
.Where(p => p.PurchaseInvoiceId == purchaseInvoiceId)
.OrderByDescending(p => p.PurchaseInvoicePaymentId)
.Select(p => new PurchaseInvoicePaymentDto(
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
.ToListAsync(ct);
}
private static PurchaseInvoicePaymentDto Map(PurchaseInvoicePayment p) => new(
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
}
@@ -0,0 +1,303 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Purchase invoice service. A purchase invoice consolidates one or more Confirmed
/// GRNs for the same vendor into a single vendor-facing document — each linked GRN
/// becomes one <see cref="PurchaseInvoiceLine"/>, a straight sum of that GRN's line
/// totals (no re-itemization). Draft invoices are fully editable (GRNs can be
/// re-selected); Post reclassifies the GRN clearing balance to Accounts Payable via a
/// real GL journal entry (mirrors <see cref="SalesInvoiceService"/>'s AR recognition);
/// vendor payments are then recorded against this aggregate (see
/// <see cref="PurchaseInvoicePaymentService"/>) rather than per-GRN.
/// </summary>
public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
{
private readonly IRepository<PurchaseInvoice> _invoices;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Grn> _grns;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glClearingAccountCode;
private readonly string _glAccountsPayableCode;
public PurchaseInvoiceService(
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_invoices = invoices;
_vendors = vendors;
_grns = grns;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
_gl = gl;
_glClearingAccountCode = configuration["PurchaseInvoice:GlClearingAccountCode"] ?? string.Empty;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
}
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
{
IQueryable<PurchaseInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.DocNo, $"%{term}%")
|| EF.Functions.ILike(x.VendorInvoiceNo, $"%{term}%")
|| EF.Functions.ILike(x.VendorSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (vendorId is not null) q = q.Where(x => x.VendorId == vendorId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.PurchaseInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<PurchaseInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().AsNoTracking()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct);
return invoice is null ? null : new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default)
{
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: null, ct);
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: null, ct);
var invoice = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new PurchaseInvoice
{
DocNo = await _numbers.NextAsync(DocumentTypes.PurchaseInvoice, token),
VendorInvoiceNo = vendorInvoiceNo,
VendorInvoiceDate = request.VendorInvoiceDate,
VendorId = request.VendorId,
VendorSnapshotName = vendor.Name,
VendorSnapshotTaxNo = vendor.TaxReg,
Status = PurchaseInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
Lines = grns.Select(BuildLine).ToList()
};
Recalculate(entity);
await _invoices.AddAsync(entity, token);
foreach (var grn in grns) grn.PurchaseInvoice = entity;
return entity;
}, ct);
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase invoice was modified by another request.", 412);
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new DomainException(ErrorCodes.PurchaseInvoiceNotEditable, $"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be edited.", 409);
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
// Free GRNs that were linked to this invoice but are no longer selected.
var newGrnIds = grns.Select(g => g.GrnId).ToHashSet();
foreach (var line in invoice.Lines)
if (line.Grn is not null && !newGrnIds.Contains(line.Grn.GrnId)) line.Grn.PurchaseInvoiceId = null;
invoice.VendorId = request.VendorId;
invoice.VendorInvoiceNo = vendorInvoiceNo;
invoice.VendorInvoiceDate = request.VendorInvoiceDate;
invoice.VendorSnapshotName = vendor.Name;
invoice.VendorSnapshotTaxNo = vendor.TaxReg;
invoice.Lines.Clear();
foreach (var line in grns.Select(BuildLine)) invoice.Lines.Add(line);
foreach (var grn in grns) grn.PurchaseInvoice = invoice;
Recalculate(invoice);
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new ConflictException($"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be posted.");
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
// Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync).
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "PURCHASE_INVOICE",
Reference = invoice.DocNo,
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glClearingAccountCode, invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(_glAccountsPayableCode, 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
]
}, token);
invoice.Status = PurchaseInvoiceStatus.Posted;
invoice.GlJournalNo = posted.JournalNo;
invoice.GlPostedAt = now;
invoice.UpdatedAt = now;
return 0;
}, ct);
return MapInvoice(invoice);
}
public async Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new ConflictException($"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be cancelled.");
var dto = MapInvoice(invoice);
// Cancelling frees the consolidated GRNs so they can be picked up by a later invoice —
// the link rows are removed (the unique index on GrnId only tracks the active link),
// while the Cancelled header row itself remains for audit history.
foreach (var line in invoice.Lines)
if (line.Grn is not null) line.Grn.PurchaseInvoiceId = null;
invoice.Lines.Clear();
invoice.Status = PurchaseInvoiceStatus.Cancelled;
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return dto with { Status = PurchaseInvoiceStatus.Cancelled, UpdatedAt = invoice.UpdatedAt };
}
public async Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default)
{
return await _grns.Query().AsNoTracking()
.Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null)
.OrderBy(g => g.PostedAt)
.Select(g => new GrnEligibleForInvoiceDto(
g.GrnId, g.DocNo, g.PostedAt ?? g.CreatedAt, g.WarehouseId, g.Lines.Count,
g.Lines.Sum(l => l.ReceivedValue), g.Lines.Sum(l => l.VatAmount), g.Lines.Sum(l => l.LineTotal)))
.ToListAsync(ct);
}
private async Task EnsureVendorInvoiceNoIsUniqueAsync(int vendorId, string vendorInvoiceNo, int? excludingPurchaseInvoiceId, CancellationToken ct)
{
var duplicate = await _invoices.Query().AsNoTracking().AnyAsync(x =>
x.VendorId == vendorId
&& x.Status != PurchaseInvoiceStatus.Cancelled
&& x.VendorInvoiceNo == vendorInvoiceNo
&& (excludingPurchaseInvoiceId == null || x.PurchaseInvoiceId != excludingPurchaseInvoiceId), ct);
if (duplicate)
throw new DomainException(ErrorCodes.DuplicateVendorInvoiceNo,
$"Vendor invoice number '{vendorInvoiceNo}' already exists for this vendor.", 409);
}
private async Task<List<Grn>> LoadAndValidateGrnsAsync(int vendorId, List<int> grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct)
{
var distinctIds = grnIds.Distinct().ToList();
var grns = await _grns.Query().Include(g => g.Lines)
.Where(g => distinctIds.Contains(g.GrnId)).ToListAsync(ct);
if (grns.Count != distinctIds.Count)
throw new DomainException(ErrorCodes.Validation, "One or more selected GRNs do not exist.", 422);
foreach (var grn in grns)
{
if (grn.Status != GrnStatus.Confirmed)
throw new DomainException(ErrorCodes.GrnNotConfirmed, $"GRN {grn.DocNo} must be confirmed before it can be invoiced.", 409);
if (grn.VendorId != vendorId)
throw new DomainException(ErrorCodes.GrnVendorMismatch, $"GRN {grn.DocNo} belongs to a different vendor.", 409);
if (grn.PurchaseInvoiceId is not null && grn.PurchaseInvoiceId != excludingPurchaseInvoiceId)
throw new DomainException(ErrorCodes.GrnAlreadyInvoiced, $"GRN {grn.DocNo} is already linked to another purchase invoice.", 409);
}
return grns;
}
private static PurchaseInvoiceLine BuildLine(Grn grn) => new()
{
Grn = grn,
GrnId = grn.GrnId,
Subtotal = grn.Lines.Sum(l => l.ReceivedValue),
VatTotal = grn.Lines.Sum(l => l.VatAmount),
LineTotal = grn.Lines.Sum(l => l.LineTotal)
};
private static void Recalculate(PurchaseInvoice invoice)
{
invoice.Subtotal = invoice.Lines.Sum(x => x.Subtotal);
invoice.VatTotal = invoice.Lines.Sum(x => x.VatTotal);
invoice.GrandTotal = invoice.Lines.Sum(x => x.LineTotal);
invoice.PaidAmount = 0m;
invoice.BalanceAmount = invoice.GrandTotal;
}
private static PurchaseInvoiceTotalsDto MapTotals(PurchaseInvoice x) => new(
x.Subtotal, x.VatTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount);
private static PurchaseInvoiceSummaryDto MapSummary(PurchaseInvoice x) => new(
x.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId,
x.VendorSnapshotName, x.Status, x.Lines.Count, MapTotals(x), x.CreatedAt, x.GlJournalNo);
private static PurchaseInvoiceDto MapInvoice(PurchaseInvoice x) => new(
x.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId,
x.VendorSnapshotName, x.VendorSnapshotTaxNo, x.Status, x.Lines.Count, x.CreatedBy, x.CreatedAt, x.UpdatedAt,
x.GlJournalNo, x.GlPostedAt, MapTotals(x),
x.Lines.OrderBy(l => l.PurchaseInvoiceLineId).Select(l => new PurchaseInvoiceLineDto(
l.PurchaseInvoiceLineId, l.GrnId, l.Grn?.DocNo ?? string.Empty, l.Grn?.PostedAt ?? default,
l.Subtotal, l.VatTotal, l.LineTotal)).ToList());
}
+2 -1
View File
@@ -26,10 +26,11 @@ public sealed class ReorderService : IReorderService
_requisitions = requisitions; _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(); var q = _reorders.Query().AsNoTracking();
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId); 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 policies = await q.OrderBy(r => r.ItemId).ToListAsync(ct);
var alerts = new List<ReorderAlertDto>(); var alerts = new List<ReorderAlertDto>();
+26 -4
View File
@@ -4,6 +4,7 @@ using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth; using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac; using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Auth.AuthHex; using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
@@ -15,20 +16,28 @@ namespace ERPCore.Services;
public sealed class RoleService : IRoleService public sealed class RoleService : IRoleService
{ {
private const string AdminRoleCode = "Admin";
private readonly IRepository<Role> _roles; private readonly IRepository<Role> _roles;
private readonly IRepository<RolePermission> _rolePermissions; private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions; private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees;
private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex; private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public RoleService( public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions, 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; _roles = roles;
_rolePermissions = rolePermissions; _rolePermissions = rolePermissions;
_permissions = permissions; _permissions = permissions;
_employees = employees;
_currentUser = currentUser;
_authHex = authHex; _authHex = authHex;
_branchConfig = branchConfig;
_uow = uow; _uow = uow;
} }
@@ -184,17 +193,30 @@ public sealed class RoleService : IRoleService
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default) 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)) 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 // AuthHex mints the RoleCode claim independently of ERPCore's stored casing
// (e.g. token "ADMIN" vs seeded "Admin"), so match case-insensitively — an // (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. // identity code differing only by case must not lock the user out of the nav.
var normalized = roleCode.Trim(); var normalized = roleCode.Trim();
var isAdmin = string.Equals(normalized, AdminRoleCode, StringComparison.OrdinalIgnoreCase);
var role = await _roles.Query().AsNoTracking() var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code.ToLower() == normalized.ToLower(), ct); .FirstOrDefaultAsync(r => r.Code.ToLower() == normalized.ToLower(), ct);
if (role is null) 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() var permissions = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId) .Where(rp => rp.RoleId == role.RoleId)
@@ -210,7 +232,7 @@ public sealed class RoleService : IRoleService
.Distinct() .Distinct()
.ToList(); .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( private static RoleDto Map(Role r) => new(
@@ -16,8 +16,8 @@ namespace ERPCore.Services;
/// until <see cref="SalesInvoice.BalanceAmount"/> reaches zero. Each payment posts its /// until <see cref="SalesInvoice.BalanceAmount"/> reaches zero. Each payment posts its
/// own real GL journal entry (Debit the selected bank-or-cash account / Credit Accounts /// own real GL journal entry (Debit the selected bank-or-cash account / Credit Accounts
/// Receivable) before being recorded, using the same call-GL-before-commit pattern as /// Receivable) before being recorded, using the same call-GL-before-commit pattern as
/// <see cref="GrnPaymentService"/> — the mirror image of it (that one credits a payable /// <see cref="PurchaseInvoicePaymentService"/> — the mirror image of it (that one debits a
/// clearing account when vendor-paid; this one credits the receivable asset when /// payable account when the vendor is paid; this one credits the receivable asset when
/// customer-paid) — so a rejected/unreachable GL post rolls back the whole payment atomically. /// customer-paid) — so a rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary> /// </summary>
public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
@@ -49,7 +49,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow; _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); IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q)) 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 (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId); if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); 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 total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(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<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger; private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<StockTransferLine> _transferLines; private readonly IRepository<StockTransferLine> _transferLines;
private readonly IRepository<Warehouse> _warehouses;
public StockService( public StockService(
IFifoCostingService fifo, IRepository<StockLayer> layers, IFifoCostingService fifo, IRepository<StockLayer> layers,
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines) IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines, IRepository<Warehouse> warehouses)
{ {
_fifo = fifo; _fifo = fifo;
_layers = layers; _layers = layers;
_ledger = ledger; _ledger = ledger;
_transferLines = transferLines; _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) public async Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
{ {
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct); var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
@@ -58,11 +71,13 @@ public sealed class StockService : IStockService
/// warehouse simply does not appear. /// warehouse simply does not appear.
/// </summary> /// </summary>
public async Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync( 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(); var layers = _layers.Query().AsNoTracking();
if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId); if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId);
if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId); 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 var grouped = layers
.GroupBy(l => new { l.ItemId, l.WarehouseId }) .GroupBy(l => new { l.ItemId, l.WarehouseId })
@@ -116,12 +131,14 @@ public sealed class StockService : IStockService
} }
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync( 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) string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
{ {
var q = _ledger.Query().AsNoTracking(); var q = _ledger.Query().AsNoTracking();
if (itemId is not null) q = q.Where(l => l.ItemId == itemId); if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId); 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 (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)); 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 // 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( 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(); 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 (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId); if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId); 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 total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId) var rows = await q.OrderByDescending(t => t.TransferId)
@@ -2,6 +2,7 @@ using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth; using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users; using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth.AuthHex; using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
@@ -16,20 +17,24 @@ public sealed class UserManagementService : IUserManagementService
{ {
private readonly IRepository<User> _users; private readonly IRepository<User> _users;
private readonly IRepository<Role> _roles; private readonly IRepository<Role> _roles;
private readonly IRepository<Employee> _employees;
private readonly IAuthUserService _authUsers; private readonly IAuthUserService _authUsers;
private readonly IAuthHexClient _authHex; private readonly IAuthHexClient _authHex;
private readonly IEmployeeUserLinkService _links; private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeService _employeeService;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public UserManagementService( public UserManagementService(
IRepository<User> users, IRepository<Role> roles, IAuthUserService authUsers, IAuthHexClient authHex, IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees, IAuthUserService authUsers,
IEmployeeUserLinkService links, IUnitOfWork uow) IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
{ {
_users = users; _users = users;
_roles = roles; _roles = roles;
_employees = employees;
_authUsers = authUsers; _authUsers = authUsers;
_authHex = authHex; _authHex = authHex;
_links = links; _links = links;
_employeeService = employeeService;
_uow = uow; _uow = uow;
} }
@@ -57,11 +62,30 @@ public sealed class UserManagementService : IUserManagementService
return user is null ? null : Map(user); 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) var role = await _roles.GetByIdAsync(request.RoleId, ct)
?? throw new NotFoundException($"Role {request.RoleId} was not found."); ?? 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(); var username = request.Username.Trim();
if (await _users.Query().AnyAsync(u => u.Username == username, ct)) if (await _users.Query().AnyAsync(u => u.Username == username, ct))
throw new ConflictException($"A user with username '{username}' already exists."); 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). // Explicit, human-confirmed link to an existing Staff record (never automatic).
if (request.LinkEmployeeId is not null) if (request.LinkEmployeeId is not null)
{
await _links.LinkAsync(request.LinkEmployeeId.Value, user.UserId, ct); 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; user.Role = role;
return Map(user); return Map(user);
+16 -7
View File
@@ -13,18 +13,24 @@ public sealed class WarehouseService : IWarehouseService
{ {
private readonly IRepository<Warehouse> _warehouses; private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins; private readonly IRepository<Bin> _bins;
private readonly IRepository<Branch> _branches;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow; 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; _warehouses = warehouses;
_bins = bins; _bins = bins;
_branches = branches;
_branchConfig = branchConfig;
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default) 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)) if (!string.IsNullOrWhiteSpace(query.Q))
{ {
var term = query.Q.Trim(); var term = query.Q.Trim();
@@ -34,7 +40,7 @@ public sealed class WarehouseService : IWarehouseService
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code) var rows = await q.OrderBy(w => w.Code)
.Skip(query.Skip).Take(query.PageSize) .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); .ToListAsync(ct);
return PagedResponse<WarehouseDto>.Create(rows, query.Page, query.PageSize, total); 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) 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); .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) 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)) if (await _warehouses.Query().AnyAsync(w => w.Code == code, ct))
throw new ConflictException($"A warehouse with code '{code}' already exists."); 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 _warehouses.AddAsync(warehouse, ct);
await _uow.SaveChangesAsync(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) public async Task<IReadOnlyList<BinDto>> ListBinsAsync(int warehouseId, CancellationToken ct = default)
+10 -5
View File
@@ -73,13 +73,18 @@ public static class ErrorCodes
// General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work) // General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work)
public const string GlRequestFailed = "GL_REQUEST_FAILED"; public const string GlRequestFailed = "GL_REQUEST_FAILED";
// GRN payments
public const string GrnNotPayable = "GRN_NOT_PAYABLE";
public const string GrnPaymentExceedsBalance = "GRN_PAYMENT_EXCEEDS_BALANCE";
public const string GrnBankAccountNotFound = "GRN_BANK_ACCOUNT_NOT_FOUND";
// Sales invoice payments // Sales invoice payments
public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE"; public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE";
public const string SalesInvoicePaymentExceedsBalance = "SALES_INVOICE_PAYMENT_EXCEEDS_BALANCE"; public const string SalesInvoicePaymentExceedsBalance = "SALES_INVOICE_PAYMENT_EXCEEDS_BALANCE";
public const string SalesInvoiceBankAccountNotFound = "SALES_INVOICE_BANK_ACCOUNT_NOT_FOUND"; public const string SalesInvoiceBankAccountNotFound = "SALES_INVOICE_BANK_ACCOUNT_NOT_FOUND";
// Purchase invoices (consolidate Confirmed GRNs; vendor payments now tracked here, not on the GRN)
public const string GrnAlreadyInvoiced = "GRN_ALREADY_INVOICED";
public const string GrnVendorMismatch = "GRN_VENDOR_MISMATCH";
public const string GrnNotConfirmed = "GRN_NOT_CONFIRMED";
public const string PurchaseInvoiceNotEditable = "PURCHASE_INVOICE_NOT_EDITABLE";
public const string DuplicateVendorInvoiceNo = "DUPLICATE_VENDOR_INVOICE_NO";
public const string PurchaseInvoiceNotPayable = "PURCHASE_INVOICE_NOT_PAYABLE";
public const string PurchaseInvoicePaymentExceedsBalance = "PURCHASE_INVOICE_PAYMENT_EXCEEDS_BALANCE";
public const string PurchaseInvoiceBankAccountNotFound = "PURCHASE_INVOICE_BANK_ACCOUNT_NOT_FOUND";
} }
+4
View File
@@ -47,5 +47,9 @@
"SalesInvoice": { "SalesInvoice": {
"GlAccountsReceivableCode": "1300" "GlAccountsReceivableCode": "1300"
}, },
"PurchaseInvoice": {
"GlClearingAccountCode": "2000",
"GlAccountsPayableCode": "2200"
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }
@@ -0,0 +1,96 @@
import { Badge } from "@/components/ui/badge"
import { PurchaseOrderStatus, RequisitionStatus, RfqStatus } from "@/types/procurement"
import { PurchaseInvoiceStatus } from "@/types/purchase-invoices"
// Fixed width (not w-fit) so every status pill is the same size regardless of
// label length, same convention as components/receiving/status-badges.tsx.
const badgeSize = "h-6 w-32 justify-center px-2.5 py-1 text-sm"
function requisitionClass(status: RequisitionStatus) {
return status === "Draft" ? "bg-warning/10 text-warning border-transparent" : "bg-success/10 text-success border-transparent"
}
export function RequisitionStatusBadge({ status }: { status: RequisitionStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${requisitionClass(status)}`}>
{status}
</Badge>
)
}
function rfqClass(status: RfqStatus) {
return status === "Open" ? "bg-success/10 text-success border-transparent" : "bg-muted text-muted-foreground border-transparent"
}
export function RfqStatusBadge({ status }: { status: RfqStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${rfqClass(status)}`}>
{status}
</Badge>
)
}
function poClass(status: PurchaseOrderStatus) {
if (status === "Draft" || status === "PendingApproval") return "bg-warning/10 text-warning border-transparent"
if (status === "Cancelled") return "bg-destructive/10 text-destructive border-transparent"
if (status === "Closed") return "bg-muted text-muted-foreground border-transparent"
return "bg-success/10 text-success border-transparent" // Approved / PartiallyReceived / FullyReceived
}
export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${poClass(status)}`}>
{status}
</Badge>
)
}
function purchaseInvoiceClass(status: PurchaseInvoiceStatus) {
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
if (status === "Posted") return "bg-success/10 text-success border-transparent"
return "bg-destructive/10 text-destructive border-transparent" // Cancelled
}
// "Posted" reads as "Approved" everywhere in the UI, matching SalesInvoiceStatusBadge.
const purchaseInvoiceStatusLabel: Record<PurchaseInvoiceStatus, string> = {
Draft: "Draft",
Posted: "Approved",
Cancelled: "Cancelled",
}
export function PurchaseInvoiceStatusBadge({ status }: { status: PurchaseInvoiceStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoiceClass(status)}`}>
{purchaseInvoiceStatusLabel[status]}
</Badge>
)
}
export type PurchaseInvoicePaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
export function purchaseInvoicePaymentStatus(paidAmount: number, balanceAmount: number): PurchaseInvoicePaymentStatus {
if (balanceAmount <= 0) return "Paid"
if (paidAmount > 0) return "PartiallyPaid"
return "Unpaid"
}
function purchaseInvoicePaymentClass(status: PurchaseInvoicePaymentStatus) {
if (status === "Paid") return "bg-success/10 text-success border-transparent"
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
return "bg-muted text-muted-foreground border-transparent" // Unpaid
}
const purchaseInvoicePaymentLabel: Record<PurchaseInvoicePaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partially paid",
Paid: "Paid",
}
export function PurchaseInvoicePaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
const status = purchaseInvoicePaymentStatus(paidAmount, balanceAmount)
return (
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoicePaymentClass(status)}`}>
{purchaseInvoicePaymentLabel[status]}
</Badge>
)
}
@@ -6,10 +6,12 @@ import { useParams } from "next/navigation"
import { ArrowLeft, BookText } from "lucide-react" import { ArrowLeft, BookText } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger" import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format" import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequePage, ChequePageIssueStatus } from "@/types/general-ledger" import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequePage, ChequePageIssueStatus } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
@@ -32,10 +34,15 @@ export default function ChequeBookDetailPage() {
const [book, setBook] = useState<ChequeBook | null>(null) const [book, setBook] = useState<ChequeBook | null>(null)
const [bankAccount, setBankAccount] = useState<CashAndBankAccountDto | null>(null) const [bankAccount, setBankAccount] = useState<CashAndBankAccountDto | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null) const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
chequeBooksApi chequeBooksApi
@@ -94,7 +101,7 @@ export default function ChequeBookDetailPage() {
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Branch</p> <p className="text-sm text-muted-foreground">Branch</p>
<p className="text-base font-medium">#{book.branchId}</p> <p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Range</p> <p className="text-sm text-muted-foreground">Range</p>
@@ -6,11 +6,14 @@ import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react" import { ArrowLeft } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger" import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateChequeBookForm } from "@/lib/validations/general-ledger" import { validateChequeBookForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger" import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -19,9 +22,11 @@ import { toast } from "@/components/ui/toast"
export default function NewChequeBookPage() { export default function NewChequeBookPage() {
const router = useRouter() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null) const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [bankAccountsError, setBankAccountsError] = useState<string | null>(null) const [bankAccountsError, setBankAccountsError] = useState<string | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchId, setBranchId] = useState("") const [branchId, setBranchId] = useState("")
const [bankAccountId, setBankAccountId] = useState("") const [bankAccountId, setBankAccountId] = useState("")
@@ -44,6 +49,21 @@ export default function NewChequeBookPage() {
.catch((err) => setBankAccountsError(errorMessage(err))) .catch((err) => setBankAccountsError(errorMessage(err)))
}, []) }, [])
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
// Pinned to the header's branch the same way as Employee/Warehouse/GRN/Sale: an Admin on a
// specific branch (or any non-admin) gets it locked here too — only "All Branches" (or the
// Branch Configuration toggle being off) leaves it as a free pick, since GL's own record
// still needs *some* branch id regardless of our toggle.
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : null
const showBranchPicker = !lockedBranchId
useEffect(() => {
if (lockedBranchId) setBranchId(String(lockedBranchId))
}, [lockedBranchId])
async function handleSubmit() { async function handleSubmit() {
const nextErrors = validateChequeBookForm({ const nextErrors = validateChequeBookForm({
branchId, branchId,
@@ -118,15 +138,29 @@ export default function NewChequeBookPage() {
</Field> </Field>
<Field data-invalid={!!errors.branchId}> <Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="cb-branch">Branch ID</FieldLabel> <FieldLabel htmlFor="cb-branch">Branch</FieldLabel>
<Input {showBranchPicker ? (
id="cb-branch" <Select<string> value={branchId} onValueChange={(v) => setBranchId(v ?? "")}>
type="number" <SelectTrigger id="cb-branch" className="w-full text-base" aria-invalid={!!errors.branchId}>
value={branchId} <SelectValue placeholder="Select a branch" />
onChange={(e) => setBranchId(e.target.value)} </SelectTrigger>
placeholder="1" <SelectContent>
aria-invalid={!!errors.branchId} {branches.map((b) => (
/> <SelectItem key={b.branchId} value={String(b.branchId)} label={b.name} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="cb-branch"
value={branches.find((b) => b.branchId === lockedBranchId)?.name ?? `Branch #${lockedBranchId}`}
readOnly
disabled
className="text-muted-foreground"
/>
)}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} /> <FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field> </Field>
@@ -6,11 +6,14 @@ import { useRouter } from "next/navigation"
import { ArrowLeft, BookText, Plus } from "lucide-react" import { ArrowLeft, BookText, Plus } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger" import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatReportDate } from "@/lib/format" import { formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequeBookStatus } from "@/types/general-ledger" import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequeBookStatus } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -27,15 +30,24 @@ const STATUS_BADGE: Record<ChequeBookStatus, string> = {
export default function ChequeBooksPage() { export default function ChequeBooksPage() {
const router = useRouter() const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const [books, setBooks] = useState<ChequeBook[] | null>(null) const [books, setBooks] = useState<ChequeBook[] | null>(null)
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null) const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
Promise.all([ Promise.all([
chequeBooksApi.list(status === "All" ? undefined : { status }), chequeBooksApi.list({
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
}),
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank), bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
]) ])
.then(([result, banks]) => { .then(([result, banks]) => {
@@ -51,9 +63,10 @@ export default function ChequeBooksPage() {
} }
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches. // bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [status]) }, [status, activeBranchId, branchingEnabled])
const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts]) const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -132,7 +145,7 @@ export default function ChequeBooksPage() {
<TableCell className="px-3 py-3.5 text-muted-foreground"> <TableCell className="px-3 py-3.5 text-muted-foreground">
{bank ? bank.accountName : `#${b.bankAccountId}`} {bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.branchId}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
{b.startChequeNo} – {b.endChequeNo} {b.startChequeNo} – {b.endChequeNo}
</TableCell> </TableCell>
@@ -1,27 +1,34 @@
"use client" "use client"
import { useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react" import { ArrowLeft } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger" import { receivedChequesApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateReceivedChequeForm } from "@/lib/validations/general-ledger" import { validateReceivedChequeForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ReceivedFromType } from "@/types/general-ledger" import { ReceivedFromType } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const NONE = "__none__"
export default function NewReceivedChequePage() { export default function NewReceivedChequePage() {
const router = useRouter() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [companyId, setCompanyId] = useState("") const [companyId, setCompanyId] = useState("")
const [branchId, setBranchId] = useState("") const [branchId, setBranchId] = useState("")
const [branches, setBranches] = useState<Branch[]>([])
const [receivedFromType, setReceivedFromType] = useState<ReceivedFromType>(ReceivedFromType.Customer) const [receivedFromType, setReceivedFromType] = useState<ReceivedFromType>(ReceivedFromType.Customer)
const [receivedFromId, setReceivedFromId] = useState("") const [receivedFromId, setReceivedFromId] = useState("")
const [receivedFromName, setReceivedFromName] = useState("") const [receivedFromName, setReceivedFromName] = useState("")
@@ -39,6 +46,19 @@ export default function NewReceivedChequePage() {
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
// Same pinning as the New Cheque Book form: locked whenever the header (or the caller's own
// branch, for non-admins) resolves to one; a free pick only on "All Branches" or the toggle off.
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : null
const showBranchPicker = !lockedBranchId
useEffect(() => {
if (lockedBranchId) setBranchId(String(lockedBranchId))
}, [lockedBranchId])
async function handleSubmit() { async function handleSubmit() {
const nextErrors = validateReceivedChequeForm({ companyId, receivedFromName, chequeNo, chequeDate, amount, receivedDate }) const nextErrors = validateReceivedChequeForm({ companyId, receivedFromName, chequeNo, chequeDate, amount, receivedDate })
setErrors(nextErrors) setErrors(nextErrors)
@@ -100,8 +120,30 @@ export default function NewReceivedChequePage() {
<FieldError errors={[errors.companyId ? { message: errors.companyId } : undefined]} /> <FieldError errors={[errors.companyId ? { message: errors.companyId } : undefined]} />
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="rc-branch">Branch ID (optional)</FieldLabel> <FieldLabel htmlFor="rc-branch">Branch (optional)</FieldLabel>
<Input id="rc-branch" type="number" value={branchId} onChange={(e) => setBranchId(e.target.value)} /> {showBranchPicker ? (
<Select<string> value={branchId || NONE} onValueChange={(v) => setBranchId(v && v !== NONE ? v : "")}>
<SelectTrigger id="rc-branch" className="w-full text-base">
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE} label="No branch" className="text-base">No branch</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)} label={b.name} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="rc-branch"
value={branches.find((b) => b.branchId === lockedBranchId)?.name ?? `Branch #${lockedBranchId}`}
readOnly
disabled
className="text-muted-foreground"
/>
)}
</Field> </Field>
<Field> <Field>
@@ -1,15 +1,18 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Inbox, Plus } from "lucide-react" import { ArrowLeft, Inbox, Plus } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger" import { receivedChequesApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format" import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ReceivedCheque, ReceivedChequeStatus } from "@/types/general-ledger" import { ReceivedCheque, ReceivedChequeStatus } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -28,16 +31,25 @@ const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
} }
export default function ReceivedChequesPage() { export default function ReceivedChequesPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null) const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [selected, setSelected] = useState<ReceivedCheque | null>(null) const [selected, setSelected] = useState<ReceivedCheque | null>(null)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
receivedChequesApi receivedChequesApi
.list(status === "All" ? undefined : { status }) .list({
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
})
.then((res) => { .then((res) => {
if (!cancelled) setCheques(res.items) if (!cancelled) setCheques(res.items)
}) })
@@ -47,7 +59,9 @@ export default function ReceivedChequesPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [status]) }, [status, activeBranchId, branchingEnabled])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
function handleUpdated(updated: ReceivedCheque) { function handleUpdated(updated: ReceivedCheque) {
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev)) setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
@@ -114,6 +128,7 @@ export default function ReceivedChequesPage() {
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead> <TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Received from</TableHead> <TableHead className="h-12 px-3 text-sm">Received from</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead> <TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead> <TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead> <TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-12 px-3 text-sm">Status</TableHead>
@@ -132,6 +147,9 @@ export default function ReceivedChequesPage() {
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell> <TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell> <TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell> <TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
@@ -5,10 +5,11 @@ import { useParams } from "next/navigation"
import { Link2, Plus, Upload } from "lucide-react" import { Link2, Plus, Upload } from "lucide-react"
import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees" import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters" import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
Branch,
Department, Department,
Designation, Designation,
EmployeeBankDetail, EmployeeBankDetail,
@@ -23,6 +24,7 @@ import {
WorkShift, WorkShift,
} from "@/types/hrm" } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -43,6 +45,7 @@ function money(n: number): string {
export default function EmployeeDetailPage() { export default function EmployeeDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const employeeId = Number(params.id) const employeeId = Number(params.id)
const { isAdmin, branchId: myBranchId, branchingEnabled } = useAuth()
const [tab, setTab] = useState<Tab>("Overview") const [tab, setTab] = useState<Tab>("Overview")
const [employee, setEmployee] = useState<EmployeeDetail | null>(null) const [employee, setEmployee] = useState<EmployeeDetail | null>(null)
@@ -53,6 +56,7 @@ export default function EmployeeDetailPage() {
const [designations, setDesignations] = useState<Designation[]>([]) const [designations, setDesignations] = useState<Designation[]>([])
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([]) const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
const [workShifts, setWorkShifts] = useState<WorkShift[]>([]) const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [bankDetails, setBankDetails] = useState<EmployeeBankDetail[]>([]) const [bankDetails, setBankDetails] = useState<EmployeeBankDetail[]>([])
@@ -92,7 +96,8 @@ export default function EmployeeDetailPage() {
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([])) employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([])) workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
hrDocumentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setDocumentTypes(r.items)).catch(() => setDocumentTypes([])) hrDocumentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setDocumentTypes(r.items)).catch(() => setDocumentTypes([]))
}, []) if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled])
useEffect(() => { useEffect(() => {
if (tab === "Bank Details") employeesApi.listBankDetails(employeeId).then(setBankDetails).catch((err) => toast.error("Could not load bank details", errorMessage(err))) if (tab === "Bank Details") employeesApi.listBankDetails(employeeId).then(setBankDetails).catch((err) => toast.error("Could not load bank details", errorMessage(err)))
@@ -271,6 +276,15 @@ export default function EmployeeDetailPage() {
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent> <SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select> </Select>
</Field> </Field>
{branchingEnabled && (
<Field>
<FieldLabel>Branch</FieldLabel>
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
<SelectTrigger disabled={!isAdmin}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{(isAdmin ? branches : branches.filter((b) => b.branchId === myBranchId || b.branchId === employee.branchId)).map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
)}
<Field><FieldLabel>EPF number</FieldLabel><Input value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field> <Field><FieldLabel>EPF number</FieldLabel><Input value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field>
<Field><FieldLabel>ETF number</FieldLabel><Input value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field> <Field><FieldLabel>ETF number</FieldLabel><Input value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
</div> </div>
@@ -5,12 +5,13 @@ import Link from "next/link"
import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react" import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees" import { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters" import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { CreateEmployeeRequest, Department, Designation, EmployeeListItem, EmploymentType, UserMatch, WorkShift } from "@/types/hrm" import { Branch, CreateEmployeeRequest, Department, Designation, EmployeeListItem, EmploymentType, UserMatch, WorkShift } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -31,10 +32,19 @@ const emptyForm: CreateEmployeeRequest = {
departmentId: 0, departmentId: 0,
designationId: 0, designationId: 0,
employmentTypeId: 0, employmentTypeId: 0,
branchId: 0,
workShiftId: 0, workShiftId: 0,
} }
export default function EmployeesPage() { export default function EmployeesPage() {
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
// The branch to pin the new hire to without asking: an Admin's currently-selected
// header branch, or a non-admin's own branch. Only when an Admin is on "All Branches"
// (no single branch implied) do they need the dropdown to pick one explicitly.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null) const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -45,6 +55,7 @@ export default function EmployeesPage() {
const [designations, setDesignations] = useState<Designation[]>([]) const [designations, setDesignations] = useState<Designation[]>([])
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([]) const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
const [workShifts, setWorkShifts] = useState<WorkShift[]>([]) const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [form, setForm] = useState<CreateEmployeeRequest>(emptyForm) const [form, setForm] = useState<CreateEmployeeRequest>(emptyForm)
@@ -57,18 +68,22 @@ export default function EmployeesPage() {
function load() { function load() {
employeesApi employeesApi
.list({ page, pageSize: PAGE_SIZE, q: q || undefined }) .list({ page, pageSize: PAGE_SIZE, q: q || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => { setEmployees(res.items); setPagination(res.pagination) }) .then((res) => { setEmployees(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
} }
useEffect(load, [page, q]) useEffect(load, [page, q, activeBranchId, branchingEnabled])
// Switching branch changes what "page 1" even means — stay on a page past the
// new filtered result set otherwise.
useEffect(() => setPage(1), [activeBranchId])
useEffect(() => { useEffect(() => {
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([])) departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([])) designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([])) employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([])) workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
}, []) if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled])
async function checkEmail(email: string) { async function checkEmail(email: string) {
if (!email.trim()) { setUserMatch(null); return } if (!email.trim()) { setUserMatch(null); return }
@@ -98,6 +113,7 @@ export default function EmployeesPage() {
if (!form.designationId) nextErrors.designationId = "Designation is required" if (!form.designationId) nextErrors.designationId = "Designation is required"
if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required" if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required" if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required"
if (branchingEnabled && !form.branchId) nextErrors.branchId = "Branch is required"
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return if (Object.keys(nextErrors).length > 0) return
@@ -126,7 +142,14 @@ export default function EmployeesPage() {
<h1 className="text-2xl font-bold text-foreground">Employees</h1> <h1 className="text-2xl font-bold text-foreground">Employees</h1>
<p className="text-base text-muted-foreground">Staff records — separate from system login accounts (see the link chip below).</p> <p className="text-base text-muted-foreground">Staff records — separate from system login accounts (see the link chip below).</p>
</div> </div>
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) resetForm() }}> <Dialog
open={open}
onOpenChange={(v) => {
setOpen(v)
if (!v) resetForm()
else if (lockedBranchId) setForm((f) => ({ ...f, branchId: lockedBranchId }))
}}
>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Employee</Button>} /> <DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Employee</Button>} />
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
@@ -173,7 +196,7 @@ export default function EmployeesPage() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field data-invalid={!!errors.departmentId}> <Field data-invalid={!!errors.departmentId}>
<FieldLabel htmlFor="e-dept">Department</FieldLabel> <FieldLabel htmlFor="e-dept">Department</FieldLabel>
<Select value={form.departmentId ? String(form.departmentId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: Number(v) }))}> <Select value={String(form.departmentId)} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: Number(v) }))}>
<SelectTrigger id="e-dept"><SelectValue placeholder="Select" /></SelectTrigger> <SelectTrigger id="e-dept"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent> <SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
</Select> </Select>
@@ -181,7 +204,7 @@ export default function EmployeesPage() {
</Field> </Field>
<Field data-invalid={!!errors.designationId}> <Field data-invalid={!!errors.designationId}>
<FieldLabel htmlFor="e-desig">Designation</FieldLabel> <FieldLabel htmlFor="e-desig">Designation</FieldLabel>
<Select value={form.designationId ? String(form.designationId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, designationId: Number(v) }))}> <Select value={String(form.designationId)} onValueChange={(v) => setForm((f) => ({ ...f, designationId: Number(v) }))}>
<SelectTrigger id="e-desig"><SelectValue placeholder="Select" /></SelectTrigger> <SelectTrigger id="e-desig"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent> <SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
</Select> </Select>
@@ -189,7 +212,7 @@ export default function EmployeesPage() {
</Field> </Field>
<Field data-invalid={!!errors.employmentTypeId}> <Field data-invalid={!!errors.employmentTypeId}>
<FieldLabel htmlFor="e-etype">Employment type</FieldLabel> <FieldLabel htmlFor="e-etype">Employment type</FieldLabel>
<Select value={form.employmentTypeId ? String(form.employmentTypeId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, employmentTypeId: Number(v) }))}> <Select value={String(form.employmentTypeId)} onValueChange={(v) => setForm((f) => ({ ...f, employmentTypeId: Number(v) }))}>
<SelectTrigger id="e-etype"><SelectValue placeholder="Select" /></SelectTrigger> <SelectTrigger id="e-etype"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent> <SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select> </Select>
@@ -197,12 +220,28 @@ export default function EmployeesPage() {
</Field> </Field>
<Field data-invalid={!!errors.workShiftId}> <Field data-invalid={!!errors.workShiftId}>
<FieldLabel htmlFor="e-shift">Work shift</FieldLabel> <FieldLabel htmlFor="e-shift">Work shift</FieldLabel>
<Select value={form.workShiftId ? String(form.workShiftId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, workShiftId: Number(v) }))}> <Select value={String(form.workShiftId)} onValueChange={(v) => setForm((f) => ({ ...f, workShiftId: Number(v) }))}>
<SelectTrigger id="e-shift"><SelectValue placeholder="Select" /></SelectTrigger> <SelectTrigger id="e-shift"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent> <SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select> </Select>
<FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} /> <FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} />
</Field> </Field>
{branchingEnabled && (
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="e-branch">Branch</FieldLabel>
{showBranchPicker ? (
<Select value={String(form.branchId)} onValueChange={(v) => setForm((f) => ({ ...f, branchId: v ? Number(v) : 0 }))}>
<SelectTrigger id="e-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
) : (
<div id="e-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
{lockedBranchName ?? "No branch assigned — contact an admin"}
</div>
)}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
)}
</div> </div>
</FieldGroup> </FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
@@ -1,16 +1,7 @@
"use client" "use client"
import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage" import { BranchMasterPage } from "@/components/settings/BranchMasterPage"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { Branch } from "@/types/hrm"
export default function BranchesPage() { export default function BranchesPage() {
return ( return <BranchMasterPage />
<CodeNameMasterPage<Branch>
title="Branches"
description="Company locations/branches — used for multi-branch employee and payroll scoping."
idOf={(b) => b.branchId}
api={branchesHrmApi}
/>
)
} }

Some files were not shown because too many files have changed in this diff Show More