new updates

This commit is contained in:
2026-09-17 14:22:45 +05:30
committed by ImanThiyanga
parent e1f19fe413
commit 8c3d62223c
261 changed files with 102091 additions and 549 deletions
@@ -33,6 +33,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -42,7 +43,7 @@ public sealed class AdjustmentService : IAdjustmentService
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_adjustments = adjustments;
@@ -54,6 +55,7 @@ public sealed class AdjustmentService : IAdjustmentService
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_notifications = notifications;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
@@ -198,6 +200,8 @@ public sealed class AdjustmentService : IAdjustmentService
return (entity, refs);
}, ct);
await _notifications.RaiseAsync("stock.adjustment.created", "Stock Adjustment Created", $"Stock adjustment {adjustment.DocNo} was created.", nameof(StockAdjustment), adjustment.AdjustmentId, ct);
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
}
@@ -53,6 +53,7 @@ public sealed class BranchConfigService : IBranchConfigService
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
config.BranchingEnabled = request.BranchingEnabled!.Value;
config.WarehouseBranchingEnabled = request.WarehouseBranchingEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -86,9 +87,34 @@ public sealed class BranchConfigService : IBranchConfigService
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).
return await ResolveDefaultBranchIdAsync(ct);
}
public async Task<bool> IsWarehouseBranchingEnabledAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct);
// The global flag off implies this is meaningless (and effectively off) too.
return (config?.BranchingEnabled ?? true) && (config?.WarehouseBranchingEnabled ?? true);
}
public async Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
{
if (await IsWarehouseBranchingEnabledAsync(ct))
{
if (requestedBranchId <= 0)
throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
return requestedBranchId;
}
return await ResolveDefaultBranchIdAsync(ct);
}
// Branching (or warehouse branching specifically) 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).
public async Task<int> ResolveDefaultBranchIdAsync(CancellationToken ct = default)
{
var existingId = await _branches.Query().AsNoTracking()
.Where(b => b.Code == DefaultBranchCode).Select(b => b.BranchId).FirstOrDefaultAsync(ct);
if (existingId != 0) return existingId;
@@ -99,5 +125,5 @@ public sealed class BranchConfigService : IBranchConfigService
return branch.BranchId;
}
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.UpdatedAt, c.UpdatedBy);
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.WarehouseBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
}
+55 -8
View File
@@ -37,6 +37,9 @@ public sealed class CountService : ICountService
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly string _glInventoryAccountCode;
private readonly string _glGainAccountCode;
private readonly string _glLossAccountCode;
@@ -45,7 +48,8 @@ public sealed class CountService : ICountService
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
IRepository<Serial> serials, IConfiguration configuration)
{
_counts = counts;
_adjustments = adjustments;
@@ -59,6 +63,9 @@ public sealed class CountService : ICountService
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_stockLayers = stockLayers;
_batches = batches;
_serials = serials;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
@@ -126,7 +133,9 @@ public sealed class CountService : ICountService
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
{
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
var count = await _counts.Query().AsNoTracking()
.Include(c => c.Lines).ThenInclude(l => l.Batch)
.Include(c => c.Lines).ThenInclude(l => l.Serial)
.FirstOrDefaultAsync(c => c.CountId == countId, ct);
return count is null ? null : Map(count);
}
@@ -143,12 +152,44 @@ public sealed class CountService : ICountService
if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {id} does not exist.", 422);
// Snapshot system quantities now (immutable once opened).
// Snapshot system quantities now (immutable once opened). Batch/serial-tracked items
// get one line per lot instead of one aggregate line, so counting can be done lot by lot.
var lines = new List<StockCountLine>();
foreach (var id in itemIds)
{
var systemQty = await _fifo.GetOnHandAsync(id, request.WarehouseId, ct);
lines.Add(new StockCountLine { ItemId = id, SystemQty = systemQty });
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == id, ct);
if (item.TrackingMode == TrackingMode.Batch)
{
var batches = await _batches.Query().AsNoTracking().Where(b => b.ItemId == id).ToListAsync(ct);
if (batches.Count == 0)
{
lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = 0 });
continue;
}
foreach (var batch in batches)
{
var systemQty = await _stockLayers.Query().AsNoTracking()
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == batch.BatchId)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
lines.Add(new StockCountLine { ItemId = id, BatchId = batch.BatchId, SystemQty = systemQty });
}
}
else if (item.TrackingMode == TrackingMode.Serial)
{
var serials = await _serials.Query().AsNoTracking()
.Where(s => s.ItemId == id && s.Status == "InStock").ToListAsync(ct);
foreach (var serial in serials)
{
var open = await _stockLayers.Query().AsNoTracking().AnyAsync(
l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == serial.SerialId && l.QtyRemaining > 0, ct);
lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 });
}
}
else
{
var systemQty = await _fifo.GetOnHandAsync(id, request.WarehouseId, ct);
lines.Add(new StockCountLine { ItemId = id, SystemQty = systemQty });
}
}
var count = await _uow.ExecuteInTransactionAsync(async token =>
@@ -174,7 +215,9 @@ public sealed class CountService : ICountService
public async Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default)
{
var count = await _counts.Query().Include(c => c.Lines)
var count = await _counts.Query()
.Include(c => c.Lines).ThenInclude(l => l.Batch)
.Include(c => c.Lines).ThenInclude(l => l.Serial)
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
?? throw new NotFoundException($"Count {countId} was not found.");
if (count.Status == CountStatus.Posted)
@@ -195,7 +238,9 @@ public sealed class CountService : ICountService
public async Task<CountPostResultDto> PostAsync(int countId, CancellationToken ct = default)
{
var count = await _counts.Query().Include(c => c.Lines)
var count = await _counts.Query()
.Include(c => c.Lines).ThenInclude(l => l.Batch)
.Include(c => c.Lines).ThenInclude(l => l.Serial)
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
?? throw new NotFoundException($"Count {countId} was not found.");
if (count.Status == CountStatus.Posted)
@@ -259,5 +304,7 @@ public sealed class CountService : ICountService
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
.Select(l => new CountLineDto(
l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance,
l.BatchId, l.Batch?.BatchNo, l.SerialId, l.Serial?.SerialNo)).ToList());
}
+8 -3
View File
@@ -15,12 +15,14 @@ public sealed class CustomerService : ICustomerService
{
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IUnitOfWork uow)
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig, IUnitOfWork uow)
{
_customers = customers;
_warehouses = warehouses;
_branchConfig = branchConfig;
_uow = uow;
}
@@ -43,7 +45,7 @@ public sealed class CustomerService : ICustomerService
.Select(c => new CustomerDto(
c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt))
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.BranchId, c.CreatedAt, c.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<CustomerDto>.Create(rows, query.Page, query.PageSize, total);
@@ -77,6 +79,7 @@ public sealed class CustomerService : ICustomerService
if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
}
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var customer = new Customer
{
CustomerCode = code,
@@ -94,6 +97,7 @@ public sealed class CustomerService : ICustomerService
CreditDays = request.CreditDays,
DefaultWarehouseId = request.DefaultWarehouseId,
Status = EntityStatus.Active,
BranchId = branchId,
CreatedAt = DateTime.UtcNow
};
@@ -143,6 +147,7 @@ public sealed class CustomerService : ICustomerService
customer.CreditLimit = request.CreditLimit;
customer.CreditDays = request.CreditDays;
customer.DefaultWarehouseId = request.DefaultWarehouseId;
customer.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
customer.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
@@ -162,7 +167,7 @@ public sealed class CustomerService : ICustomerService
private static CustomerDto Map(Customer c) => new(
c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt);
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.BranchId, c.CreatedAt, c.UpdatedAt);
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
+23 -1
View File
@@ -24,11 +24,19 @@ public sealed class DashboardService : IDashboardService
private readonly IRepository<Requisition> _requisitions;
private readonly IRepository<StockCount> _counts;
private readonly IRepository<Rfq> _rfqs;
private readonly IRepository<LeaveRequest> _leaveRequests;
private readonly IRepository<SalesInvoice> _salesInvoices;
private readonly IReorderService _reorder;
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
// has none), so "overdue" is approximated as a Posted invoice with an outstanding
// balance older than this many days from its invoice date.
private const int OverdueAgeDays = 30;
public DashboardService(
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
IReorderService reorder)
{
_layers = layers;
@@ -37,6 +45,8 @@ public sealed class DashboardService : IDashboardService
_requisitions = requisitions;
_counts = counts;
_rfqs = rfqs;
_leaveRequests = leaveRequests;
_salesInvoices = salesInvoices;
_reorder = reorder;
}
@@ -78,12 +88,24 @@ public sealed class DashboardService : IDashboardService
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
// LeaveRequest carries no BranchId (like PO/Requisition/RFQ) — global regardless
// of the selected branch.
var pendingLeaveRequests = await _leaveRequests.Query().AsNoTracking()
.CountAsync(l => l.Status == LeaveRequestStatus.Submitted, ct);
var overdueSince = DateTime.UtcNow.AddDays(-OverdueAgeDays);
var salesInvoices = _salesInvoices.Query().AsNoTracking();
if (branchId is not null) salesInvoices = salesInvoices.Where(i => i.BranchId == branchId);
var overdueSalesInvoices = await salesInvoices.CountAsync(
i => i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0 && i.InvoiceDate < overdueSince, ct);
// PageSize:1 is enough — GetAlertsAsync computes the full alert count before paging.
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
.Pagination.TotalItems;
return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs);
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingLeaveRequests, overdueSalesInvoices);
}
}
@@ -0,0 +1,77 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Search;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Backs the global command palette (⌘K). Fans a single free-text term out across
/// Items, Purchase Orders, Sales Slips (invoices) and Employees in parallel and
/// returns a combined, capped result set — no persistence, read-only.
/// </summary>
public sealed class GlobalSearchService : IGlobalSearchService
{
private readonly IRepository<Item> _items;
private readonly IRepository<PurchaseOrder> _purchaseOrders;
private readonly IRepository<SalesSlip> _salesSlips;
private readonly IRepository<Employee> _employees;
public GlobalSearchService(
IRepository<Item> items,
IRepository<PurchaseOrder> purchaseOrders,
IRepository<SalesSlip> salesSlips,
IRepository<Employee> employees)
{
_items = items;
_purchaseOrders = purchaseOrders;
_salesSlips = salesSlips;
_employees = employees;
}
public async Task<IReadOnlyList<GlobalSearchResultDto>> SearchAsync(string term, int takePerType = 6, CancellationToken ct = default)
{
var q = term?.Trim() ?? string.Empty;
if (q.Length < 2) return Array.Empty<GlobalSearchResultDto>();
var pattern = $"%{q}%";
// Sequential, not parallel: these share one scoped DbContext, which EF Core
// does not allow concurrent operations against.
var items = await _items.Query().AsNoTracking()
.Where(i => EF.Functions.ILike(i.Sku, pattern) || EF.Functions.ILike(i.Name, pattern))
.OrderBy(i => i.Name)
.Take(takePerType)
.Select(i => new GlobalSearchResultDto(GlobalSearchResultType.Item, i.ItemId, i.Sku, i.Name, i.Sku))
.ToListAsync(ct);
var pos = await _purchaseOrders.Query().AsNoTracking()
.Where(p => EF.Functions.ILike(p.DocNo, pattern))
.OrderByDescending(p => p.PoId)
.Take(takePerType)
.Select(p => new GlobalSearchResultDto(GlobalSearchResultType.PurchaseOrder, p.PoId, p.DocNo, p.DocNo, p.Status.ToString()))
.ToListAsync(ct);
var invoices = await _salesSlips.Query().AsNoTracking()
.Where(s => EF.Functions.ILike(s.SlipNo, pattern) || EF.Functions.ILike(s.CustomerSnapshotName, pattern))
.OrderByDescending(s => s.SalesSlipId)
.Take(takePerType)
.Select(s => new GlobalSearchResultDto(GlobalSearchResultType.Invoice, s.SalesSlipId, s.SlipNo, s.SlipNo, s.CustomerSnapshotName))
.ToListAsync(ct);
var employees = await _employees.Query().AsNoTracking()
.Where(e => EF.Functions.ILike(e.FullName, pattern) || EF.Functions.ILike(e.EmployeeCode, pattern))
.OrderBy(e => e.FullName)
.Take(takePerType)
.Select(e => new GlobalSearchResultDto(GlobalSearchResultType.Employee, e.EmployeeId, e.EmployeeCode, e.FullName, e.EmployeeCode))
.ToListAsync(ct);
var results = new List<GlobalSearchResultDto>();
results.AddRange(items);
results.AddRange(pos);
results.AddRange(invoices);
results.AddRange(employees);
return results;
}
}
+26 -24
View File
@@ -35,12 +35,14 @@ public sealed class GrnService : IGrnService
private readonly IRepository<Batch> _batches;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<TaxRate> _taxRates;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly INotificationService _notifications;
private readonly string _glInventoryAccountCode;
private readonly string _glVatRecoverableAccountCode;
private readonly string _glClearingAccountCode;
@@ -49,9 +51,9 @@ public sealed class GrnService : IGrnService
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
{
_grns = grns;
_pos = pos;
@@ -63,12 +65,14 @@ public sealed class GrnService : IGrnService
_batches = batches;
_layers = layers;
_ledger = ledger;
_taxRates = taxRates;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_notifications = notifications;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
_glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
@@ -180,7 +184,13 @@ public sealed class GrnService : IGrnService
// Derived figures are always computed server-side, never accepted from the client.
var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero);
var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero);
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
// input.VatPct supplied is an explicit override; omitted (null) resolves from the
// item's assigned TaxRate — see ISalesDomainService.ResolveLineTaxPctAsync's sales-side
// equivalent of this same "Item.TaxRate drives the default, don't hand-type it" rule.
var vatPct = input.VatPct ?? (item.TaxRateId is int taxRateId
? await _taxRates.Query().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct)
: 0m);
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty);
@@ -196,7 +206,7 @@ public sealed class GrnService : IGrnService
PoUnitPrice = poUnitPrice,
DiscountPct = input.DiscountPct,
NetUnitCost = netUnitCost,
VatPct = input.VatPct,
VatPct = vatPct,
VatAmount = vatAmount,
ReceivedValue = receivedValue,
LineTotal = receivedValue + vatAmount,
@@ -224,6 +234,8 @@ public sealed class GrnService : IGrnService
return entity;
}, ct);
await _notifications.RaiseAsync("grn.created", "GRN Created", $"GRN {grn.DocNo} was created.", nameof(Grn), grn.GrnId, ct);
return Map(grn);
}
@@ -316,6 +328,8 @@ public sealed class GrnService : IGrnService
return 0;
}, ct);
await _notifications.RaiseAsync("grn.confirmed", "GRN Confirmed", $"GRN {grn.DocNo} was confirmed.", nameof(Grn), grn.GrnId, ct);
return new GrnConfirmResultDto(
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty,
createdLayers.Select(ToCreatedLayer).ToList(),
@@ -345,26 +359,14 @@ public sealed class GrnService : IGrnService
return new ReleaseLineResultDto(grnLineId, HoldStatus.Available);
}
// Reject: remove the held stock from on-hand and post a reversing ledger entry.
// Linking rejected stock to a formal purchase return is deferred (§3.4).
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
line.HoldStatus = HoldStatus.Rejected;
var layers = await _layers.Query()
.Where(l => l.GrnLineId == grnLineId && l.QtyRemaining > 0).ToListAsync(token);
foreach (var layer in layers)
{
var bal = await _fifo.GetOnHandAsync(layer.ItemId, layer.WarehouseId, token) - layer.QtyRemaining;
await _fifo.PostLedgerAsync(
layer.ItemId, layer.WarehouseId, line.BinId, layer.BatchId, null, actor,
Direction.Out, layer.QtyRemaining, layer.UnitCost, bal, DocumentTypes.Grn, grn.GrnId, now, token);
layer.QtyRemaining = 0;
}
return 0;
}, ct);
// Reject: the held stock stays exactly where it is — still on the layer, still
// excluded from issuable on-hand (FifoCostingService.ConsumeAsync excludes both
// OnHold and Rejected lines) — until a Purchase Return actually ships it back to
// the vendor. That return (not this action) posts the real outbound ledger entry,
// scoped to this line's own layer(s), so the movement is attributable to a real
// document instead of vanishing against the GRN itself (§3.4).
line.HoldStatus = HoldStatus.Rejected;
await _uow.SaveChangesAsync(ct);
return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
}
@@ -3,12 +3,14 @@ using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ERPCore.Services.Hrm;
@@ -30,13 +32,16 @@ public sealed class EmployeeService : IEmployeeService
private readonly IFileStorageService _fileStorage;
private readonly IBranchConfigService _branchConfig;
private readonly IBranchAccessService _branchAccess;
private readonly IAuthHexClient _authHex;
private readonly ILogger<EmployeeService> _logger;
private readonly IUnitOfWork _uow;
public EmployeeService(
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
IRepository<EmployeeDocument> documents, IRepository<EmployeeSalaryStructure> salaryStructures,
IRepository<EmployeeLoan> loans, IRepository<LeaveBalance> leaveBalances, IRepository<PayrollLine> payrollLines,
IFileStorageService fileStorage, IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
IFileStorageService fileStorage, IBranchConfigService branchConfig, IBranchAccessService branchAccess,
IAuthHexClient authHex, ILogger<EmployeeService> logger, IUnitOfWork uow)
{
_employees = employees;
_users = users;
@@ -49,6 +54,8 @@ public sealed class EmployeeService : IEmployeeService
_fileStorage = fileStorage;
_branchConfig = branchConfig;
_branchAccess = branchAccess;
_authHex = authHex;
_logger = logger;
_uow = uow;
}
@@ -212,7 +219,7 @@ public sealed class EmployeeService : IEmployeeService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int employeeId, CancellationToken ct = default)
public async Task<EmployeeDeleteResultDto> DeleteAsync(int employeeId, bool deleteUserAccount, CancellationToken ct = default)
{
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
@@ -220,13 +227,19 @@ public sealed class EmployeeService : IEmployeeService
if (await _payrollLines.Query().AnyAsync(l => l.EmployeeId == employeeId, ct))
throw new DomainException(ErrorCodes.EmployeeHasPayrollHistory, "This employee has payroll history and cannot be deleted.", 409);
// A linked system-user account survives the employee row (Employee.UserId → User is
// Restrict, so cascading it away isn't even an option) — deactivate it (same as the
// Active/Deactivate toggle on Settings > Users) rather than leaving a login standing
// with no staff record behind it. The User row itself is never removed.
if (employee.UserId is not null)
var linkedUserId = employee.UserId;
// A linked system-user account is Employee.UserId → User (Restrict), so it never
// cascades away with the employee row automatically. Default behaviour: deactivate it
// (same as the Active/Deactivate toggle on Settings > Users) rather than leaving a
// login standing with no staff record behind it. The caller may instead ask for the
// account to be permanently removed (deleteUserAccount) — attempted separately, below,
// after the employee row itself is safely gone, so a hard-delete failure (the user has
// real linked business/audit records — see AuditLog/GRN/etc. Restrict FKs) never blocks
// the employee deletion that was actually confirmed.
if (linkedUserId is not null && !deleteUserAccount)
{
var linkedUser = await _users.GetByIdAsync(employee.UserId.Value, ct);
var linkedUser = await _users.GetByIdAsync(linkedUserId.Value, ct);
if (linkedUser is not null) linkedUser.Status = EntityStatus.Inactive;
}
@@ -252,6 +265,47 @@ public sealed class EmployeeService : IEmployeeService
_employees.Remove(employee);
await _uow.SaveChangesAsync(ct);
if (linkedUserId is null || !deleteUserAccount)
return new EmployeeDeleteResultDto(UserAccountDeleted: false, UserAccountDeactivated: linkedUserId is not null);
// Attempted as its own save, after the employee is already committed, so a Restrict-FK
// failure here (the user posted real transactions — GRNs, purchase orders, audit log
// entries, ...) can't roll back the employee deletion the caller already confirmed.
var user = await _users.GetByIdAsync(linkedUserId.Value, ct);
if (user is null) return new EmployeeDeleteResultDto(UserAccountDeleted: false, UserAccountDeactivated: false);
string? userEmail = user.Email;
string? username = user.Username;
try
{
_users.Remove(user);
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
user.Status = EntityStatus.Inactive;
await _uow.SaveChangesAsync(ct);
return new EmployeeDeleteResultDto(UserAccountDeleted: false, UserAccountDeactivated: true, AuthHexIdentityPurged: false);
}
// The local User row is gone, but AuthHex's own duplicate check (CanRegisterUser) has no
// notion of "deleted" — its stale identity would otherwise permanently block this email/
// username from ever being registered again. Best-effort: AuthHex may not be reachable, or
// the admin deleteUser function may not be deployed/configured yet (AdminApiKey unset on
// either side) — either way, the local deletion the caller confirmed must not roll back
// over this, so failures here are only logged, never rethrown.
try
{
var result = await _authHex.DeleteUserAsync(userEmail, null, null, username, ct);
return new EmployeeDeleteResultDto(UserAccountDeleted: true, UserAccountDeactivated: false, AuthHexIdentityPurged: result.DeletedCount > 0);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Deleted local User for employee {EmployeeId}, but purging the AuthHex identity (email={Email}, username={Username}) failed — that email/username may still be blocked from re-registration.", employeeId, userEmail, username);
return new EmployeeDeleteResultDto(UserAccountDeleted: true, UserAccountDeactivated: false, AuthHexIdentityPurged: false);
}
}
public async Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default)
@@ -1,10 +1,12 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ERPCore.Services.Hrm;
@@ -13,12 +15,18 @@ public sealed class EmployeeUserLinkService : IEmployeeUserLinkService
{
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly IAuthHexClient _authHex;
private readonly ILogger<EmployeeUserLinkService> _logger;
private readonly IUnitOfWork _uow;
public EmployeeUserLinkService(IRepository<Employee> employees, IRepository<User> users, IUnitOfWork uow)
public EmployeeUserLinkService(
IRepository<Employee> employees, IRepository<User> users,
IAuthHexClient authHex, ILogger<EmployeeUserLinkService> logger, IUnitOfWork uow)
{
_employees = employees;
_users = users;
_authHex = authHex;
_logger = logger;
_uow = uow;
}
@@ -66,8 +74,41 @@ public sealed class EmployeeUserLinkService : IEmployeeUserLinkService
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
var linkedUserId = employee.UserId;
employee.UserId = null;
employee.UpdatedAt = DateTime.UtcNow;
string? userEmail = null;
string? username = null;
var userRemoved = false;
if (linkedUserId is not null)
{
var linkedUser = await _users.GetByIdAsync(linkedUserId.Value, ct);
if (linkedUser is not null)
{
userEmail = linkedUser.Email;
username = linkedUser.Username;
_users.Remove(linkedUser);
userRemoved = true;
}
}
await _uow.SaveChangesAsync(ct);
// Best-effort, same as EmployeeService.DeleteAsync's hard-delete path — AuthHex's own
// duplicate check has no notion of "deleted", so without this the email/username stays
// permanently blocked from re-registration even though the local User row is gone.
if (userRemoved)
{
try
{
await _authHex.DeleteUserAsync(userEmail, null, null, username, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unlinked and deleted local User for employee {EmployeeId}, but purging the AuthHex identity (email={Email}, username={Username}) failed.", employeeId, userEmail, username);
}
}
}
}
@@ -20,4 +20,17 @@ public interface IBranchConfigService
/// default branch when branching is disabled — creating that default branch on first use.
/// </summary>
Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
/// <summary>True when Warehouses specifically should ask for/show a branch. Independent
/// of <see cref="IsBranchingEnabledAsync"/>, but never true when that is false.</summary>
Task<bool> IsWarehouseBranchingEnabledAsync(CancellationToken ct = default);
/// <summary>Same as <see cref="ResolveBranchIdAsync"/> but gated on
/// <see cref="IsWarehouseBranchingEnabledAsync"/> instead of the global flag.</summary>
Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
/// <summary>The deployment's single default branch ("BR-000"/"Main Branch"), created on
/// first use if it doesn't exist yet. Used as a fallback branch for document types that
/// don't yet carry an explicit branch (docs/BRANCH_ISOLATION_PLAN.md).</summary>
Task<int> ResolveDefaultBranchIdAsync(CancellationToken ct = default);
}
@@ -21,7 +21,12 @@ public interface IEmployeeService
/// 409 if the employee has any payroll history, since that's financial/audit record that
/// must never be destroyed.
/// </summary>
Task DeleteAsync(int employeeId, CancellationToken ct = default);
/// <summary>
/// Permanently deletes the employee. When <paramref name="deleteUserAccount"/> is true and the
/// employee has a linked login, the User row is also hard-deleted if possible — it falls back to
/// deactivating the account when the user has real linked business/audit records instead.
/// </summary>
Task<EmployeeDeleteResultDto> DeleteAsync(int employeeId, bool deleteUserAccount = false, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default);
@@ -0,0 +1,8 @@
using ERPCore.Dtos.Search;
namespace ERPCore.Services.Interfaces;
public interface IGlobalSearchService
{
Task<IReadOnlyList<GlobalSearchResultDto>> SearchAsync(string term, int takePerType = 6, CancellationToken ct = default);
}
@@ -24,4 +24,8 @@ public interface IItemService
Task SetStatusAsync(int itemId, EntityStatus status, CancellationToken ct = default);
Task<ItemReorderSettingsDto> UpdateReorderAsync(int itemId, UpdateReorderRequest request, CancellationToken ct = default);
/// <summary>Barcode/RFID scan lookup — item barcode/SKU, or a batch/serial barcode/no
/// (optionally scoped to <paramref name="itemId"/> first). Null when nothing matches.</summary>
Task<ItemScanResultDto?> ScanAsync(string code, int? itemId, CancellationToken ct = default);
}
@@ -0,0 +1,30 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Resolves whether a role can reach a given nav module — the same
/// <c>RolePermission</c> data <c>/auth/me</c> already reduces to <c>navCodes</c>
/// (<see cref="RoleService.GetMeAsync"/>), reused here to gate the API itself instead
/// of only the sidebar (docs/02-SECURITY.md AR-01).
/// </summary>
public interface INavPermissionService
{
/// <summary>
/// True when <paramref name="roleCode"/> is granted <paramref name="navCode"/> itself,
/// or any sub-nav item nested under it (code prefixed <c>"{navCode}."</c>). Does not
/// special-case Admin — callers (e.g. <see cref="Infra.Auth.NavAuthorizationHandler"/>)
/// decide whether Admin bypasses this entirely.
/// </summary>
Task<bool> HasAccessAsync(string? roleCode, string navCode, CancellationToken ct = default);
/// <summary>
/// True when <paramref name="roleCode"/>'s grant on <paramref name="navCode"/> (exact
/// code match — no sub-nav prefix expansion, unlike <see cref="HasAccessAsync"/>, since
/// an action applies to one specific module's permission row) includes
/// <paramref name="action"/> in its <c>RolePermission.Actions</c> bitmask. False if the
/// role has no permission row for that exact nav code at all. Does not special-case
/// Admin — see <see cref="HasAccessAsync"/>.
/// </summary>
Task<bool> HasActionAsync(string? roleCode, string navCode, RolePermissionAction action, CancellationToken ct = default);
}
@@ -0,0 +1,23 @@
using ERPCore.Dtos.Notifications;
namespace ERPCore.Services.Interfaces;
public interface INotificationService
{
/// <summary>
/// Raises a module event as a broadcast notification to all active users,
/// but only when the module's InApp channel is enabled in NotificationConfig.
/// No-op (and no row written) when the channel is disabled.
/// </summary>
Task RaiseAsync(
string moduleKey,
string title,
string message,
string? relatedEntityType = null,
int? relatedEntityId = null,
CancellationToken ct = default);
Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default);
Task MarkAllReadAsync(int userId, CancellationToken ct = default);
}
@@ -1,15 +1,23 @@
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Issues gap-controlled, per-year document numbers (FR-X-03). Call inside the
/// document's UoW transaction so the reserved number rolls back with the document
/// on failure.
/// Issues gap-controlled, per-branch, per-year document numbers (FR-X-03,
/// docs/BRANCH_ISOLATION_PLAN.md §5). Call inside the document's UoW transaction so the
/// reserved number rolls back with the document on failure.
/// </summary>
public interface INumberSequenceService
{
/// <summary>
/// Reserve and return the next number for <paramref name="docType"/> in the
/// current year, formatted e.g. <c>PO-2026-00042</c>.
/// Reserve and return the next number for <paramref name="docType"/> in
/// <paramref name="branchId"/> for the current year, formatted e.g.
/// <c>PO-BR-001-2026-00042</c>.
/// </summary>
Task<string> NextAsync(string docType, int branchId, CancellationToken ct = default);
/// <summary>
/// Convenience overload for document types that don't yet carry an explicit branch
/// (e.g. HRM/production/day-end documents, still out of scope for branch isolation —
/// docs/BRANCH_ISOLATION_PLAN.md). Resolves to the deployment's default branch.
/// </summary>
Task<string> NextAsync(string docType, CancellationToken ct = default);
}
@@ -14,10 +14,23 @@ public interface IPurchaseOrderService
Task<ETagged<PurchaseOrderDto>?> GetAsync(int poId, CancellationToken ct = default);
Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default);
Task<ETagged<PurchaseOrderDto>> UpdateAsync(int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default);
/// <summary>
/// PendingApproval → Approved. 409 PO_NOT_PENDING_APPROVAL if the PO isn't awaiting
/// approval; 403 PO_APPROVAL_FORBIDDEN if the caller isn't an Admin or is the PO's
/// own creator (self-approval is blocked).
/// </summary>
Task<PurchaseOrderDto> ApproveAsync(int poId, CancellationToken ct = default);
/// <summary>
/// PendingApproval → Draft, so the requester can revise and resubmit. Same
/// caller restrictions as <see cref="ApproveAsync"/>.
/// </summary>
Task<PurchaseOrderDto> RejectAsync(int poId, string? reason, CancellationToken ct = default);
Task<PurchaseOrderDto> CancelAsync(int poId, string? reason, CancellationToken ct = default);
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
/// <summary>Submit a Draft PO (Draft → PendingApproval). 409 PO_NOT_EDITABLE if not Draft.</summary>
Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default);
/// <summary>Delete a PO — permitted only while Draft, else 409 PO_NOT_EDITABLE.</summary>
@@ -28,6 +28,16 @@ public interface ISalesDomainService
bool allowManualOverride,
CancellationToken ct = default);
/// <summary>
/// <paramref name="requestedTaxPct"/> supplied (non-null) is an explicit override, used
/// as-is. Null resolves from the item's assigned <c>TaxRateId</c> — its
/// <see cref="Domain.Entities.TaxRate.Percentage"/> at this moment — or 0 if the item has
/// no tax rate assigned. This is what makes <c>Item.TaxRate</c> (formerly the cosmetic
/// <c>TaxClass</c> label) actually drive tax instead of every line needing a hand-typed
/// percentage.
/// </summary>
Task<decimal> ResolveLineTaxPctAsync(int itemId, decimal? requestedTaxPct, CancellationToken ct = default);
SalesLineComputation ComputeLine(
decimal qty,
decimal freeQty,
@@ -0,0 +1,20 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Support;
namespace ERPCore.Services.Interfaces;
public interface ISupportTicketService
{
Task<ETagged<SupportTicketDto>> CreateAsync(int userId, CreateSupportTicketRequest request, CancellationToken ct = default);
/// <summary>Admins see every ticket; other users see only their own.</summary>
Task<IReadOnlyList<SupportTicketSummaryDto>> GetListAsync(int userId, bool isAdmin, CancellationToken ct = default);
Task<ETagged<SupportTicketDto>> GetByIdAsync(int ticketId, int userId, bool isAdmin, CancellationToken ct = default);
Task<ETagged<SupportTicketDto>> AddMessageAsync(int ticketId, int userId, bool isAdmin, AddSupportTicketMessageRequest request, uint expectedRowVersion, CancellationToken ct = default);
/// <summary>Admin-only status transition.</summary>
Task<ETagged<SupportTicketDto>> UpdateStatusAsync(int ticketId, int userId, bool isAdmin, UpdateSupportTicketStatusRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.TaxRates;
namespace ERPCore.Services.Interfaces;
/// <summary>Tax rate master business logic — see <see cref="Domain.Entities.TaxRate"/>.</summary>
public interface ITaxRateService
{
Task<PagedResponse<TaxRateDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<TaxRateDto>?> GetAsync(int taxRateId, CancellationToken ct = default);
Task<ETagged<TaxRateDto>> CreateAsync(CreateTaxRateRequest request, CancellationToken ct = default);
Task<ETagged<TaxRateDto>> UpdateAsync(int taxRateId, UpdateTaxRateRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int taxRateId, EntityStatus status, CancellationToken ct = default);
}
@@ -1,4 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Users;
@@ -28,4 +29,12 @@ public interface IUserManagementService
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
/// <summary>
/// Admin-only escape hatch: permanently deletes a stale AuthHex identity by email/mobile/NIC/
/// username so that identifier stops blocking new registrations (AuthHex's own duplicate
/// check has no concept of "deleted" — see docs/PROGRESS.md's AuthHex dedupe notes). Does not
/// touch ERPCore's local `users` table at all — only AuthHex's separate identity store.
/// </summary>
Task<DeleteAuthHexUserResult> PurgeAuthHexIdentityAsync(PurgeAuthHexIdentityRequest request, CancellationToken ct = default);
}
@@ -9,6 +9,8 @@ public interface IWarehouseService
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default);
Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default);
Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default);
Task<WarehouseDto> UpdateAsync(int warehouseId, UpdateWarehouseRequest request, CancellationToken ct = default);
Task DeleteAsync(int warehouseId, CancellationToken ct = default);
Task<IReadOnlyList<BinDto>> ListBinsAsync(int warehouseId, CancellationToken ct = default);
Task<BinDto> CreateBinAsync(int warehouseId, CreateBinRequest request, CancellationToken ct = default);
+107 -9
View File
@@ -30,11 +30,15 @@ public sealed class ItemService : IItemService
private readonly IRepository<SubCategory> _subCategories;
private readonly IRepository<Brand> _brands;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<TaxRate> _taxRates;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IProductConfigService _config;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
public ItemService(
@@ -43,11 +47,15 @@ public sealed class ItemService : IItemService
IRepository<SubCategory> subCategories,
IRepository<Brand> brands,
IRepository<Uom> uoms,
IRepository<TaxRate> taxRates,
IRepository<Vendor> vendors,
IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers,
IRepository<StockLedger> stockLedger,
IRepository<Batch> batches,
IRepository<Serial> serials,
IProductConfigService config,
INotificationService notifications,
IUnitOfWork uow)
{
_items = items;
@@ -55,11 +63,15 @@ public sealed class ItemService : IItemService
_subCategories = subCategories;
_brands = brands;
_uoms = uoms;
_taxRates = taxRates;
_vendors = vendors;
_warehouses = warehouses;
_stockLayers = stockLayers;
_stockLedger = stockLedger;
_batches = batches;
_serials = serials;
_config = config;
_notifications = notifications;
_uow = uow;
}
@@ -86,10 +98,11 @@ public sealed class ItemService : IItemService
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths,
i.TaxRateId, i.TaxRate == null ? null : i.TaxRate.Name, i.TaxRate == null ? null : i.TaxRate.Percentage,
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status))
i.Status, i.Barcode))
.ToListAsync(ct);
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
@@ -99,6 +112,7 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query().AsNoTracking()
.Include(i => i.ReorderSettings)
.Include(i => i.TaxRate)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
@@ -109,10 +123,14 @@ public sealed class ItemService : IItemService
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
var barcode = NormalizeBarcode(request.Barcode);
if (barcode is not null && await _items.Query().AnyAsync(i => i.Barcode == barcode, ct))
throw new DomainException(ErrorCodes.Validation, $"An item with barcode '{barcode}' already exists.", 422);
var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, config, ct);
request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct);
ValidateFeatureConfiguration(request, config);
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
@@ -133,7 +151,7 @@ public sealed class ItemService : IItemService
TrackingMode = request.TrackingMode,
Warranty = request.Warranty,
WarrantyPeriodMonths = warrantyPeriodMonths,
TaxClass = request.TaxClass,
TaxRateId = request.TaxRateId,
SellingMinPrice = request.SellingMinPrice,
SellingMaxPrice = request.SellingMaxPrice,
SellingFlatPrice = request.SellingFlatPrice,
@@ -141,12 +159,17 @@ public sealed class ItemService : IItemService
ContentUnit = request.ContentUnit,
ContentBaseQty = contentBaseQty,
ContentBaseUnit = contentBaseUnit,
Barcode = barcode,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _items.AddAsync(item, ct);
await _uow.SaveChangesAsync(ct);
if (item.TaxRateId is not null)
item.TaxRate = await _taxRates.Query().AsNoTracking().FirstAsync(t => t.TaxRateId == item.TaxRateId, ct);
await _notifications.RaiseAsync("item.created", "Item Created", $"Item {item.Sku} ({item.Name}) was created.", nameof(Item), item.ItemId, ct);
return new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
}
@@ -156,6 +179,7 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.Include(i => i.TaxRate)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
@@ -166,10 +190,15 @@ public sealed class ItemService : IItemService
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
var barcode = NormalizeBarcode(request.Barcode);
if (barcode is not null && !string.Equals(item.Barcode, barcode, StringComparison.Ordinal)
&& await _items.Query().AnyAsync(i => i.Barcode == barcode && i.ItemId != itemId, ct))
throw new DomainException(ErrorCodes.Validation, $"An item with barcode '{barcode}' already exists.", 422);
var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, config, ct);
request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct);
ValidateFeatureConfiguration(request, config);
// The base UOM is the sole meaning of every quantity recorded against this item —
@@ -197,7 +226,7 @@ public sealed class ItemService : IItemService
item.TrackingMode = request.TrackingMode;
item.Warranty = request.Warranty;
item.WarrantyPeriodMonths = warrantyPeriodMonths;
item.TaxClass = request.TaxClass;
item.TaxRateId = request.TaxRateId;
item.SellingMinPrice = request.SellingMinPrice;
item.SellingMaxPrice = request.SellingMaxPrice;
item.SellingFlatPrice = request.SellingFlatPrice;
@@ -205,9 +234,14 @@ public sealed class ItemService : IItemService
item.ContentUnit = request.ContentUnit;
item.ContentBaseQty = contentBaseQty;
item.ContentBaseUnit = contentBaseUnit;
item.Barcode = barcode;
item.UpdatedAt = DateTime.UtcNow;
await SaveGuardingConcurrencyAsync(ct);
item.TaxRate = item.TaxRateId is null
? null
: await _taxRates.Query().AsNoTracking().FirstAsync(t => t.TaxRateId == item.TaxRateId, ct);
await _notifications.RaiseAsync("item.updated", "Item Updated", $"Item {item.Sku} ({item.Name}) was updated.", nameof(Item), item.ItemId, ct);
return new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
}
@@ -285,7 +319,7 @@ public sealed class ItemService : IItemService
/// it is advisory and honoured by the frontend only (docs/11 §2.8).
/// </summary>
private async Task ValidateReferencesAsync(
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId,
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, int? taxRateId,
ProductConfigDto config, CancellationToken ct)
{
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
@@ -339,6 +373,16 @@ public sealed class ItemService : IItemService
if (vendor.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} is inactive.", 422);
}
if (taxRateId is not null)
{
var taxRate = await _taxRates.Query().AsNoTracking()
.FirstOrDefaultAsync(t => t.TaxRateId == taxRateId, ct);
if (taxRate is null)
throw new DomainException(ErrorCodes.Validation, $"Tax rate {taxRateId} does not exist.", 422);
if (taxRate.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Tax rate {taxRateId} is inactive.", 422);
}
}
private static void ValidateFeatureConfiguration(CreateItemRequest request, ProductConfigDto config)
@@ -410,13 +454,67 @@ public sealed class ItemService : IItemService
}
}
/// <summary>Trims and blank-collapses a barcode, mirroring <c>Sku.Trim()</c> — a stray
/// space or an empty string would otherwise save as a value that never matches a real
/// scan, or collide with another item's blank barcode under the unique index.</summary>
private static string? NormalizeBarcode(string? barcode)
{
var trimmed = barcode?.Trim();
return string.IsNullOrEmpty(trimmed) ? null : trimmed;
}
public async Task<ItemScanResultDto?> ScanAsync(string code, int? itemId, CancellationToken ct = default)
{
code = code.Trim();
if (code.Length == 0)
throw new DomainException(ErrorCodes.Validation, "code is required.", 422);
if (itemId is not null)
{
var batchForItem = await _batches.Query().AsNoTracking().Include(b => b.Item)
.FirstOrDefaultAsync(b => b.ItemId == itemId && (b.Barcode == code || b.BatchNo == code), ct);
if (batchForItem is not null) return ToScanResult(batchForItem);
var serialForItem = await _serials.Query().AsNoTracking().Include(s => s.Item)
.FirstOrDefaultAsync(s => s.ItemId == itemId && (s.Barcode == code || s.SerialNo == code), ct);
if (serialForItem is not null) return ToScanResult(serialForItem);
}
var item = await _items.Query().AsNoTracking()
.FirstOrDefaultAsync(i => i.Barcode == code || i.Sku == code, ct);
if (item is not null) return ToScanResult(item);
var serial = await _serials.Query().AsNoTracking().Include(s => s.Item)
.FirstOrDefaultAsync(s => s.Barcode == code || s.SerialNo == code, ct);
if (serial is not null) return ToScanResult(serial);
var batch = await _batches.Query().AsNoTracking().Include(b => b.Item)
.FirstOrDefaultAsync(b => b.Barcode == code || b.BatchNo == code, ct);
if (batch is not null) return ToScanResult(batch);
return null;
}
private static ItemScanResultDto ToScanResult(Item i) => new(
i.ItemId, i.Sku, i.Name, i.TrackingMode, i.BaseUomId, "Item",
null, null, null, null, null, null);
private static ItemScanResultDto ToScanResult(Batch b) => new(
b.Item!.ItemId, b.Item.Sku, b.Item.Name, b.Item.TrackingMode, b.Item.BaseUomId, "Batch",
b.BatchId, b.BatchNo, b.ExpiryDate, null, null, null);
private static ItemScanResultDto ToScanResult(Serial s) => new(
s.Item!.ItemId, s.Item.Sku, s.Item.Name, s.Item.TrackingMode, s.Item.BaseUomId, "Serial",
null, null, null, s.SerialId, s.SerialNo, s.Status);
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths,
i.TaxRateId, i.TaxRate?.Name, i.TaxRate?.Percentage,
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status,
i.Status, i.Barcode,
i.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
@@ -0,0 +1,113 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace ERPCore.Services;
/// <summary>
/// See <see cref="INavPermissionService"/>. A role's permitted nav-code set changes
/// rarely (an admin editing Roles → Permissions) but is looked up on every gated
/// request, so it's cached per role code for a short window rather than re-querying
/// <c>RolePermission</c> every call — a Roles→Permissions edit takes up to
/// <see cref="CacheTtl"/> to take effect for already-issued tokens of that role.
/// </summary>
public sealed class NavPermissionService : INavPermissionService
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(2);
private readonly IRepository<Role> _roles;
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IMemoryCache _cache;
public NavPermissionService(IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IMemoryCache cache)
{
_roles = roles;
_rolePermissions = rolePermissions;
_cache = cache;
}
public async Task<bool> HasAccessAsync(string? roleCode, string navCode, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(roleCode)) return false;
var codes = await GetNavCodesAsync(roleCode.Trim(), ct);
return codes.Contains(navCode) || codes.Any(c => c.StartsWith(navCode + ".", StringComparison.OrdinalIgnoreCase));
}
public async Task<bool> HasActionAsync(string? roleCode, string navCode, RolePermissionAction action, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(roleCode)) return false;
var actionsByCode = await GetActionsByNavCodeAsync(roleCode.Trim(), ct);
return actionsByCode.TryGetValue(navCode, out var granted) && granted.HasFlag(action);
}
private async Task<HashSet<string>> GetNavCodesAsync(string roleCode, CancellationToken ct)
{
var cacheKey = $"nav-permission-codes:{roleCode.ToLowerInvariant()}";
if (_cache.TryGetValue(cacheKey, out HashSet<string>? cached) && cached is not null)
return cached;
// Same shape as RoleService.GetMeAsync's navCodes computation — mirrored rather
// than shared because that method also carries the unauthenticated/no-role
// early-return branches this lookup doesn't need.
var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code.ToLower() == roleCode.ToLower(), ct);
var codes = role is null
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
: (await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId)
.Include(rp => rp.Permission!).ThenInclude(p => p.NavItem)
.Include(rp => rp.Permission!).ThenInclude(p => p.SubNavItem)
.Select(rp => rp.Permission!)
.ToListAsync(ct))
.Select(p => p.NavItem?.Code ?? p.SubNavItem?.Code)
.Where(c => c is not null)
.Select(c => c!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
_cache.Set(cacheKey, codes, CacheTtl);
return codes;
}
private async Task<Dictionary<string, RolePermissionAction>> GetActionsByNavCodeAsync(string roleCode, CancellationToken ct)
{
var cacheKey = $"nav-permission-actions:{roleCode.ToLowerInvariant()}";
if (_cache.TryGetValue(cacheKey, out Dictionary<string, RolePermissionAction>? cached) && cached is not null)
return cached;
var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code.ToLower() == roleCode.ToLower(), ct);
var actionsByCode = new Dictionary<string, RolePermissionAction>(StringComparer.OrdinalIgnoreCase);
if (role is not null)
{
var rows = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId)
.Include(rp => rp.Permission!).ThenInclude(p => p.NavItem)
.Include(rp => rp.Permission!).ThenInclude(p => p.SubNavItem)
.Select(rp => new { rp.Actions, rp.Permission!.NavItem, rp.Permission!.SubNavItem })
.ToListAsync(ct);
foreach (var row in rows)
{
var code = row.NavItem?.Code ?? row.SubNavItem?.Code;
if (code is null) continue;
// A role could in principle hold two permission rows resolving to the same
// code (shouldn't happen given one Permission per NavItem/SubNavItem, but
// OR rather than overwrite so a duplicate never silently narrows access).
actionsByCode[code] = actionsByCode.TryGetValue(code, out var existing)
? existing | row.Actions
: row.Actions;
}
}
_cache.Set(cacheKey, actionsByCode, CacheTtl);
return actionsByCode;
}
}
@@ -0,0 +1,111 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Domain.Notifications;
using ERPCore.Dtos.Notifications;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class NotificationService : INotificationService
{
private readonly IRepository<Notification> _notifications;
private readonly IRepository<NotificationRecipient> _recipients;
private readonly IRepository<User> _users;
private readonly INotificationConfigService _config;
private readonly IUnitOfWork _uow;
public NotificationService(
IRepository<Notification> notifications,
IRepository<NotificationRecipient> recipients,
IRepository<User> users,
INotificationConfigService config,
IUnitOfWork uow)
{
_notifications = notifications;
_recipients = recipients;
_users = users;
_config = config;
_uow = uow;
}
public async Task RaiseAsync(
string moduleKey,
string title,
string message,
string? relatedEntityType = null,
int? relatedEntityId = null,
CancellationToken ct = default)
{
var channels = await _config.ResolveEnabledChannelsAsync(moduleKey, ct);
if (!channels.Contains(NotificationChannel.InApp)) return;
var activeUserIds = await _users.Query().AsNoTracking()
.Where(u => u.Status == EntityStatus.Active)
.Select(u => u.UserId)
.ToListAsync(ct);
if (activeUserIds.Count == 0) return;
var notification = new Notification
{
ModuleKey = moduleKey,
Title = title,
Message = message,
RelatedEntityType = relatedEntityType,
RelatedEntityId = relatedEntityId,
CreatedAt = DateTime.UtcNow
};
notification.Recipients = activeUserIds
.Select(userId => new NotificationRecipient { UserId = userId })
.ToList();
await _notifications.AddAsync(notification, ct);
await _uow.SaveChangesAsync(ct);
}
public async Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default)
{
var query = _recipients.Query().AsNoTracking()
.Where(r => r.UserId == userId);
var unreadCount = await query.CountAsync(r => !r.IsRead, ct);
var items = await query
.Include(r => r.Notification)
.OrderByDescending(r => r.Notification!.CreatedAt)
.Take(take)
.Select(r => new NotificationDto(
r.Notification!.NotificationId,
r.Notification.ModuleKey,
r.Notification.Title,
r.Notification.Message,
r.Notification.RelatedEntityType,
r.Notification.RelatedEntityId,
r.Notification.CreatedAt,
r.IsRead))
.ToListAsync(ct);
return new NotificationFeedDto(items, unreadCount);
}
public async Task MarkAllReadAsync(int userId, CancellationToken ct = default)
{
var unread = await _recipients.Query()
.Where(r => r.UserId == userId && !r.IsRead)
.ToListAsync(ct);
if (unread.Count == 0) return;
var now = DateTime.UtcNow;
foreach (var recipient in unread)
{
recipient.IsRead = true;
recipient.ReadAt = now;
}
await _uow.SaveChangesAsync(ct);
}
}
@@ -1,5 +1,7 @@
using System.Data;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using ERPCore.Domain.Entities;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
@@ -8,23 +10,42 @@ namespace ERPCore.Services;
/// <summary>
/// Atomic document-number issuer. Uses a single <c>INSERT … ON CONFLICT … DO UPDATE
/// … RETURNING</c> so concurrent issues for the same (docType, year) cannot get the
/// same number (the row is locked for the duration of the upsert). Runs as a direct
/// … RETURNING</c> so concurrent issues for the same (docType, branchId, year) cannot get
/// the same number (the row is locked for the duration of the upsert). Runs as a direct
/// ADO.NET command enlisted in the DbContext's current transaction so it commits or
/// rolls back with the document (FR-X-03). EF's <c>SqlQuery</c> is avoided here
/// because it wraps the statement in a subquery, which PostgreSQL disallows for a
/// data-modifying statement.
/// data-modifying statement. Numbering is per-branch (docs/BRANCH_ISOLATION_PLAN.md §5):
/// the sequence key is (doc_type, branch_id, year) and the format includes the branch code,
/// e.g. <c>PO-BR-001-2026-00042</c>.
/// </summary>
public sealed class NumberSequenceService : INumberSequenceService
{
private readonly ErpDbContext _db;
private readonly IRepository<Branch> _branches;
private readonly IBranchConfigService _branchConfig;
public NumberSequenceService(ErpDbContext db) => _db = db;
public NumberSequenceService(ErpDbContext db, IRepository<Branch> branches, IBranchConfigService branchConfig)
{
_db = db;
_branches = branches;
_branchConfig = branchConfig;
}
public async Task<string> NextAsync(string docType, CancellationToken ct = default)
{
var defaultBranchId = await _branchConfig.ResolveDefaultBranchIdAsync(ct);
return await NextAsync(docType, defaultBranchId, ct);
}
public async Task<string> NextAsync(string docType, int branchId, CancellationToken ct = default)
{
var year = DateTime.UtcNow.Year;
var branchCode = await _branches.Query().AsNoTracking()
.Where(b => b.BranchId == branchId).Select(b => b.Code).FirstOrDefaultAsync(ct)
?? throw new InvalidOperationException($"Branch {branchId} was not found — cannot issue a document number.");
var conn = _db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
await conn.OpenAsync(ct);
@@ -32,20 +53,21 @@ public sealed class NumberSequenceService : INumberSequenceService
await using var cmd = conn.CreateCommand();
cmd.Transaction = _db.Database.CurrentTransaction?.GetDbTransaction();
cmd.CommandText = """
INSERT INTO number_sequences (doc_type, year, last_number)
VALUES (@docType, @year, 1)
ON CONFLICT (doc_type, year)
INSERT INTO number_sequences (doc_type, branch_id, year, last_number)
VALUES (@docType, @branchId, @year, 1)
ON CONFLICT (doc_type, branch_id, year)
DO UPDATE SET last_number = number_sequences.last_number + 1
RETURNING last_number;
""";
AddParam(cmd, "docType", docType);
AddParam(cmd, "branchId", branchId);
AddParam(cmd, "year", year);
var result = await cmd.ExecuteScalarAsync(ct)
?? throw new InvalidOperationException($"Failed to issue a document number for '{docType}'.");
var next = Convert.ToInt64(result);
return $"{docType}-{year}-{next:D5}";
return $"{docType}-{branchCode}-{year}-{next:D5}";
}
private static void AddParam(IDbCommand cmd, string name, object value)
@@ -90,14 +90,17 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: null, ct);
var branchId = grns[0].BranchId;
var invoice = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new PurchaseInvoice
{
DocNo = await _numbers.NextAsync(DocumentTypes.PurchaseInvoice, token),
DocNo = await _numbers.NextAsync(DocumentTypes.PurchaseInvoice, branchId, token),
VendorInvoiceNo = vendorInvoiceNo,
VendorInvoiceDate = request.VendorInvoiceDate,
VendorId = request.VendorId,
BranchId = branchId,
VendorSnapshotName = vendor.Name,
VendorSnapshotTaxNo = vendor.TaxReg,
Status = PurchaseInvoiceStatus.Draft,
@@ -142,6 +145,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
if (line.Grn is not null && !newGrnIds.Contains(line.Grn.GrnId)) line.Grn.PurchaseInvoiceId = null;
invoice.VendorId = request.VendorId;
invoice.BranchId = grns[0].BranchId;
invoice.VendorInvoiceNo = vendorInvoiceNo;
invoice.VendorInvoiceDate = request.VendorInvoiceDate;
invoice.VendorSnapshotName = vendor.Name;
@@ -265,6 +269,12 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
throw new DomainException(ErrorCodes.GrnAlreadyInvoiced, $"GRN {grn.DocNo} is already linked to another purchase invoice.", 409);
}
// A purchase invoice consolidates GRNs into one branch-scoped document — reject a
// mix (docs/BRANCH_ISOLATION_PLAN.md "Purchase Invoice (AP)").
var branchIds = grns.Select(g => g.BranchId).Distinct().ToList();
if (branchIds.Count > 1)
throw new DomainException(ErrorCodes.GrnBranchMismatch, "Selected GRNs belong to different branches and cannot be consolidated onto one purchase invoice.", 409);
return grns;
}
@@ -290,11 +300,11 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
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.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId, x.BranchId,
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.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId, x.BranchId,
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(
@@ -14,9 +14,10 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Purchase-order service. Phase 1 auto-approves on creation (FR-PROC-04),
/// PO is freely editable while open (FR-PROC-05), totals are computed server-side
/// (02-SECURITY C.2), and cancel is blocked once any receipt exists.
/// Purchase-order service. Issuing a PO (create non-draft, or submit a Draft) routes it
/// through PendingApproval → Approved (FR-PROC-04); a Draft is freely editable
/// (FR-PROC-05); totals are computed server-side (02-SECURITY C.2); cancel is blocked
/// once any receipt exists.
/// </summary>
public sealed class PurchaseOrderService : IPurchaseOrderService
{
@@ -30,12 +31,15 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
private readonly IRepository<Warehouse> _warehouses;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly INotificationService _notifications;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public PurchaseOrderService(
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
IBranchConfigService branchConfig, IUnitOfWork uow)
{
_pos = pos;
_vendors = vendors;
@@ -45,6 +49,8 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
_warehouses = warehouses;
_numbers = numbers;
_currentUser = currentUser;
_notifications = notifications;
_branchConfig = branchConfig;
_uow = uow;
}
@@ -66,7 +72,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
.ToListAsync(ct);
var summaries = rows.Select(p => new PurchaseOrderSummaryDto(
p.PoId, p.DocNo, p.VendorId, p.Status, p.ApprovalRequired, p.CreatedAt, ComputeTotals(p.Lines))).ToList();
p.PoId, p.DocNo, p.VendorId, p.BranchId, p.Status, p.ApprovalRequired, p.CreatedBy, p.CreatedAt, ComputeTotals(p.Lines))).ToList();
return PagedResponse<PurchaseOrderSummaryDto>.Create(summaries, query.Page, query.PageSize, total);
}
@@ -82,20 +88,22 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default)
{
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
await EnsureLinesInBranchAsync(branchId, request.Lines.Select(l => l.WarehouseId), ct);
var actor = _currentUser.AuditUserId;
var po = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.PurchaseOrder, token);
var docNo = await _numbers.NextAsync(DocumentTypes.PurchaseOrder, branchId, token);
var entity = new PurchaseOrder
{
DocNo = docNo,
VendorId = request.VendorId,
BranchId = branchId,
RequisitionId = request.RequisitionId,
// Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04),
// unless the caller explicitly saves a Draft (editable/deletable until submitted).
ApprovalRequired = false,
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
// Not a Draft ⇒ issued immediately, same as submitting one (FR-PROC-04).
ApprovalRequired = !request.SaveAsDraft,
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.PendingApproval,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(ToLine).ToList()
@@ -104,6 +112,10 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
return entity;
}, ct);
await _notifications.RaiseAsync("purchase.order.created", "Purchase Order Created", $"Purchase order {po.DocNo} was created.", nameof(PurchaseOrder), po.PoId, ct);
if (po.Status == PurchaseOrderStatus.PendingApproval)
await _notifications.RaiseAsync("purchase.order.pending_approval", "Purchase Order Awaiting Approval", $"Purchase order {po.DocNo} is awaiting approval.", nameof(PurchaseOrder), po.PoId, ct);
return new ETagged<PurchaseOrderDto>(Map(po), po.RowVersion);
}
@@ -122,6 +134,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be edited.", 409);
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
await EnsureLinesInBranchAsync(po.BranchId, request.Lines.Select(l => l.WarehouseId), ct);
po.VendorId = request.VendorId;
po.RequisitionId = request.RequisitionId;
@@ -145,22 +158,52 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
}
public async Task<PurchaseOrderDto> ApproveAsync(int poId, CancellationToken ct = default)
{
var po = await LoadForApprovalDecisionAsync(poId, ct);
po.Status = PurchaseOrderStatus.Approved;
po.ApprovedBy = _currentUser.AuditUserId;
po.ApprovedAt = DateTime.UtcNow;
po.UpdatedAt = po.ApprovedAt;
await _uow.SaveChangesAsync(ct);
await _notifications.RaiseAsync("purchase.order.approved", "Purchase Order Approved", $"Purchase order {po.DocNo} was approved.", nameof(PurchaseOrder), po.PoId, ct);
return Map(po);
}
public async Task<PurchaseOrderDto> RejectAsync(int poId, string? reason, CancellationToken ct = default)
{
var po = await LoadForApprovalDecisionAsync(poId, ct);
// Back to Draft, not Cancelled — a reject is "revise and resubmit", not "abandon".
po.Status = PurchaseOrderStatus.Draft;
po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
var suffix = string.IsNullOrWhiteSpace(reason) ? "." : $": {reason}";
await _notifications.RaiseAsync("purchase.order.rejected", "Purchase Order Rejected", $"Purchase order {po.DocNo} was rejected{suffix}", nameof(PurchaseOrder), po.PoId, ct);
return Map(po);
}
/// <summary>Shared load + gate for Approve/Reject: must be PendingApproval, and the
/// caller must be an Admin who isn't the PO's own creator (self-approval is blocked).</summary>
private async Task<PurchaseOrder> LoadForApprovalDecisionAsync(int poId, CancellationToken ct)
{
var po = await _pos.Query()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
// Phase 1 no-op: POs are already Approved on creation. Kept for the future
// approval workflow (PendingApproval → Approved) — FR-PROC-04.
if (po.Status == PurchaseOrderStatus.PendingApproval)
{
po.Status = PurchaseOrderStatus.Approved;
po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
if (po.Status != PurchaseOrderStatus.PendingApproval)
throw new DomainException(ErrorCodes.PoNotPendingApproval, $"Purchase order {poId} is {po.Status} and is not awaiting approval.", 409);
return Map(po);
if (!_currentUser.IsAdmin)
throw new DomainException(ErrorCodes.PoApprovalForbidden, "Only an Admin may approve or reject a purchase order.", 403);
if (po.CreatedBy == _currentUser.AuditUserId)
throw new DomainException(ErrorCodes.PoApprovalForbidden, "You cannot approve or reject your own purchase order.", 403);
return po;
}
public async Task<PurchaseOrderDto> CancelAsync(int poId, string? reason, CancellationToken ct = default)
@@ -193,10 +236,11 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
if (po.Status != PurchaseOrderStatus.Draft)
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409);
// Phase 1: no value gate, so a submitted draft goes straight to Approved (FR-PROC-04).
po.Status = PurchaseOrderStatus.Approved;
po.Status = PurchaseOrderStatus.PendingApproval;
po.ApprovalRequired = true;
po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
await _notifications.RaiseAsync("purchase.order.pending_approval", "Purchase Order Awaiting Approval", $"Purchase order {po.DocNo} is awaiting approval.", nameof(PurchaseOrder), po.PoId, ct);
return Map(po);
}
@@ -260,6 +304,20 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct);
}
/// <summary>A PO is constrained to one branch (docs/BRANCH_ISOLATION_PLAN.md — Open
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.</summary>
private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable<int> warehouseIds, CancellationToken ct)
{
var ids = warehouseIds.Distinct().ToList();
var mismatched = await _warehouses.Query().AsNoTracking()
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId)
.Select(w => w.WarehouseId)
.ToListAsync(ct);
if (mismatched.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"Warehouse(s) {string.Join(", ", mismatched)} do not belong to this purchase order's branch.", 422);
}
private static async Task EnsureAllExistAsync(
IQueryable<int> keySource, IEnumerable<int> requested, string label, CancellationToken ct)
{
@@ -271,8 +329,8 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
}
private static PurchaseOrderDto Map(PurchaseOrder p) => new(
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
p.PoId, p.DocNo, p.VendorId, p.BranchId, p.RequisitionId, p.Status, p.ApprovalRequired,
p.CreatedBy, p.ApprovedBy, p.ApprovedAt, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
l.PoLineId, l.ItemId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
}
@@ -15,11 +15,15 @@ namespace ERPCore.Services;
/// <summary>
/// Purchase-return service (FR-PROC-08). Auto-posts with a mandatory Return reason
/// code and generates an outbound stock movement via the shared
/// <see cref="IStockMutator"/> (FIFO consume, row-locked; over-return beyond
/// available → STOCK_NEGATIVE_BLOCKED). Single UoW transaction. Also posts a real GL
/// journal entry reversing the Inventory/Clearing lines the original <see cref="Grn"/>
/// posted — reuses <c>Grn</c>'s own account config since it's reversing that posting.
/// code. A line referencing a <c>Rejected</c> GRN line consumes specifically that
/// line's own FIFO layer(s) — up to whatever quantity is still sitting there — so the
/// stock a GRN reject quarantined is only ever released back to the vendor through a
/// real, auditable document (§3.4); every other line (no GRN line, or one that isn't
/// Rejected) still uses the shared <see cref="IStockMutator"/> against general on-hand
/// (FIFO consume, row-locked; over-return beyond available → STOCK_NEGATIVE_BLOCKED).
/// Single UoW transaction. Also posts a real GL journal entry reversing the
/// Inventory/Clearing lines the original <see cref="Grn"/> posted — reuses
/// <c>Grn</c>'s own account config since it's reversing that posting.
/// </summary>
public sealed class PurchaseReturnService : IPurchaseReturnService
{
@@ -29,10 +33,13 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -41,8 +48,9 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IRepository<StockLedger> ledger, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{
_returns = returns;
_vendors = vendors;
@@ -50,10 +58,13 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
_items = items;
_reasonCodes = reasonCodes;
_grnLines = grnLines;
_layers = layers;
_ledger = ledger;
_mutator = mutator;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_notifications = notifications;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
@@ -116,6 +127,11 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
if (reason.Context != ReasonContext.Return)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422);
// Lines that reference a Rejected GRN line are resolved against that line's own
// layer(s) below, not the generic mutator — track which by GrnLineId so the two
// paths don't double-handle the same line.
var rejectedGrnLineIds = new HashSet<int>();
foreach (var line in request.Lines)
{
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
@@ -126,11 +142,25 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
?? throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} does not exist.", 422);
if (grnLine.ItemId != line.ItemId)
throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} is for a different item.", 422);
if (grnLine.HoldStatus == HoldStatus.Rejected)
{
var remaining = await _layers.Query().AsNoTracking()
.Where(l => l.GrnLineId == line.GrnLineId).SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (line.Qty > remaining)
throw new DomainException(ErrorCodes.ReturnQtyExceedsRejected,
$"GRN line {line.GrnLineId} has {remaining} rejected unit(s) left to return, but {line.Qty} was requested.", 422);
rejectedGrnLineIds.Add(line.GrnLineId.Value);
}
}
}
var now = DateTime.UtcNow;
var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, -l.Qty)).ToList();
var actor = _currentUser.AuditUserId;
var deltas = request.Lines
.Where(l => l.GrnLineId is null || !rejectedGrnLineIds.Contains(l.GrnLineId.Value))
.Select(l => new StockDelta(l.ItemId, null, null, -l.Qty)).ToList();
var rejectedLines = request.Lines.Where(l => l.GrnLineId is not null && rejectedGrnLineIds.Contains(l.GrnLineId.Value)).ToList();
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
{
@@ -154,7 +184,38 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
await _returns.AddAsync(ret, token);
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.PurchaseReturn, ret.ReturnId, now, deltas, token);
var refs = new List<StockLedger>(await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.PurchaseReturn, ret.ReturnId, now, deltas, token));
// Rejected-GRN-line returns: consume oldest-first from that exact line's own
// layer(s) only — never the item's general on-hand — so cost and quarantine
// status stay tied to the receipt that was actually rejected.
var balances = new Dictionary<int, decimal>();
foreach (var line in rejectedLines)
{
var layers = await _layers.Query()
.Where(l => l.GrnLineId == line.GrnLineId && l.QtyRemaining > 0)
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.ToListAsync(token);
if (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, request.WarehouseId, token);
var remaining = line.Qty;
foreach (var layer in layers)
{
if (remaining <= 0) break;
var take = Math.Min(remaining, layer.QtyRemaining);
layer.QtyRemaining -= take;
remaining -= take;
bal -= take;
var entry = await _fifo.PostLedgerAsync(
layer.ItemId, layer.WarehouseId, null, layer.BatchId, null, actor,
Direction.Out, take, layer.UnitCost, bal, DocumentTypes.PurchaseReturn, ret.ReturnId, now, token);
refs.Add(entry);
}
balances[line.ItemId] = bal;
}
var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0)
@@ -180,6 +241,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
return (ret, refs);
}, ct);
await _notifications.RaiseAsync("purchase.return.created", "Purchase Return Created", $"Purchase return {entity.DocNo} was created.", nameof(PurchaseReturn), entity.ReturnId, ct);
// Map ledger ids after commit so they are populated.
return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList());
}
@@ -18,16 +18,18 @@ public sealed class RequisitionService : IRequisitionService
private readonly IRepository<Item> _items;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public RequisitionService(
IRepository<Requisition> requisitions, IRepository<Item> items,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow)
{
_requisitions = requisitions;
_items = items;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
}
@@ -46,7 +48,7 @@ public sealed class RequisitionService : IRequisitionService
var rows = await q.OrderByDescending(r => r.RequisitionId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new RequisitionSummaryDto(
r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt, r.Lines.Count))
r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.BranchId, r.CreatedAt, r.Lines.Count))
.ToListAsync(ct);
return PagedResponse<RequisitionSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -64,14 +66,16 @@ public sealed class RequisitionService : IRequisitionService
{
await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct);
var actor = _currentUser.AuditUserId;
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var req = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Requisition, token);
var docNo = await _numbers.NextAsync(DocumentTypes.Requisition, branchId, token);
var entity = new Requisition
{
DocNo = docNo,
RequestedBy = actor,
BranchId = branchId,
Status = RequisitionStatus.Draft,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(l => new RequisitionLine
@@ -115,7 +119,7 @@ public sealed class RequisitionService : IRequisitionService
}
private static RequisitionDto Map(Requisition r) => new(
r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt,
r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.BranchId, r.CreatedAt,
r.Lines.OrderBy(l => l.ReqLineId)
.Select(l => new RequisitionLineDto(l.ReqLineId, l.ItemId, l.Qty, l.RequiredBy))
.ToList());
+13 -5
View File
@@ -51,7 +51,7 @@ public sealed class RfqService : IRfqService
var rows = await q.OrderByDescending(r => r.RfqId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new RfqSummaryDto(
r.RfqId, r.DocNo, r.RequisitionId, r.Status,
r.RfqId, r.DocNo, r.RequisitionId, r.BranchId, r.Status,
r.Lines.Count,
// Correlated subquery: there is no Rfq.Quotations navigation to count.
_quotations.Query().Count(qt => qt.RfqId == r.RfqId)))
@@ -70,19 +70,27 @@ public sealed class RfqService : IRfqService
public async Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default)
{
if (!await _requisitions.Query().AnyAsync(r => r.RequisitionId == request.RequisitionId, ct))
throw new DomainException(ErrorCodes.Validation, $"Requisition {request.RequisitionId} does not exist.", 422);
var requisitionBranchId = await _requisitions.Query().AsNoTracking()
.Where(r => r.RequisitionId == request.RequisitionId)
.Select(r => (int?)r.BranchId)
.FirstOrDefaultAsync(ct)
?? throw new DomainException(ErrorCodes.Validation, $"Requisition {request.RequisitionId} does not exist.", 422);
await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct);
await EnsureVendorsExistAsync(request.VendorIds, ct);
var rfq = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Rfq, token);
// An RFQ is raised from a Requisition and inherits its branch — a PO's branch
// span is constrained to one branch (docs/BRANCH_ISOLATION_PLAN.md §1), and
// keeping the RFQ on the same branch as its source requisition keeps that chain
// consistent end to end.
var docNo = await _numbers.NextAsync(DocumentTypes.Rfq, requisitionBranchId, token);
var entity = new Rfq
{
DocNo = docNo,
RequisitionId = request.RequisitionId,
BranchId = requisitionBranchId,
Status = RfqStatus.Open,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(l => new RfqLine { ItemId = l.ItemId, Qty = l.Qty }).ToList()
@@ -178,6 +186,6 @@ public sealed class RfqService : IRfqService
}
private static RfqDto MapRfq(Rfq r) => new(
r.RfqId, r.DocNo, r.RequisitionId, r.Status,
r.RfqId, r.DocNo, r.RequisitionId, r.BranchId, r.Status,
r.Lines.OrderBy(l => l.RfqLineId).Select(l => new RfqLineDto(l.RfqLineId, l.ItemId, l.Qty)).ToList());
}
+20 -9
View File
@@ -158,13 +158,15 @@ public sealed class RoleService : IRoleService
var granted = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == roleId)
.Include(rp => rp.Permission)
.Select(rp => rp.Permission!)
.Select(rp => new { rp.Actions, rp.Permission!.NavItemId, rp.Permission!.SubNavItemId })
.ToListAsync(ct);
return new RolePermissionsDto(
roleId,
granted.Where(p => p.NavItemId is not null).Select(p => p.NavItemId!.Value).ToList(),
granted.Where(p => p.SubNavItemId is not null).Select(p => p.SubNavItemId!.Value).ToList());
granted.Where(p => p.SubNavItemId is not null).Select(p => p.SubNavItemId!.Value).ToList(),
granted.Where(p => p.NavItemId is not null).ToDictionary(p => p.NavItemId!.Value, p => p.Actions),
granted.Where(p => p.SubNavItemId is not null).ToDictionary(p => p.SubNavItemId!.Value, p => p.Actions));
}
public async Task<RolePermissionsDto> AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default)
@@ -177,14 +179,22 @@ public sealed class RoleService : IRoleService
var navIds = request.NavItemIds.Distinct().ToList();
var subNavIds = request.SubNavItemIds.Distinct().ToList();
var permissionIds = await _permissions.Query().AsNoTracking()
var permissions = await _permissions.Query().AsNoTracking()
.Where(p => (p.NavItemId != null && navIds.Contains(p.NavItemId.Value))
|| (p.SubNavItemId != null && subNavIds.Contains(p.SubNavItemId.Value)))
.Select(p => p.PermissionId)
.ToListAsync(ct);
foreach (var permissionId in permissionIds)
await _rolePermissions.AddAsync(new RolePermission { RoleId = roleId, PermissionId = permissionId }, ct);
foreach (var permission in permissions)
{
var actions = permission.NavItemId is int navId && request.NavItemActions.TryGetValue(navId, out var navActions)
? navActions
: permission.SubNavItemId is int subNavId && request.SubNavItemActions.TryGetValue(subNavId, out var subNavActions)
? subNavActions
: RolePermissionAction.All;
await _rolePermissions.AddAsync(
new RolePermission { RoleId = roleId, PermissionId = permission.PermissionId, Actions = actions }, ct);
}
await _uow.SaveChangesAsync(ct);
@@ -204,9 +214,10 @@ public sealed class RoleService : IRoleService
var branchId = employee?.BranchId;
var branchName = employee?.Branch?.Name;
var branchingEnabled = await _branchConfig.IsBranchingEnabledAsync(ct);
var warehouseBranchingEnabled = await _branchConfig.IsWarehouseBranchingEnabledAsync(ct);
if (string.IsNullOrWhiteSpace(roleCode))
return new MeResponseDto(null, null, Array.Empty<string>(), false, branchId, branchName, branchingEnabled);
return new MeResponseDto(null, null, Array.Empty<string>(), false, branchId, branchName, branchingEnabled, warehouseBranchingEnabled);
// AuthHex mints the RoleCode claim independently of ERPCore's stored casing
// (e.g. token "ADMIN" vs seeded "Admin"), so match case-insensitively — an
@@ -216,7 +227,7 @@ public sealed class RoleService : IRoleService
var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code.ToLower() == normalized.ToLower(), ct);
if (role is null)
return new MeResponseDto(roleCode, null, Array.Empty<string>(), isAdmin, branchId, branchName, branchingEnabled);
return new MeResponseDto(roleCode, null, Array.Empty<string>(), isAdmin, branchId, branchName, branchingEnabled, warehouseBranchingEnabled);
var permissions = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId)
@@ -232,7 +243,7 @@ public sealed class RoleService : IRoleService
.Distinct()
.ToList();
return new MeResponseDto(role.Code, role.Name, navCodes, isAdmin, branchId, branchName, branchingEnabled);
return new MeResponseDto(role.Code, role.Name, navCodes, isAdmin, branchId, branchName, branchingEnabled, warehouseBranchingEnabled);
}
private static RoleDto Map(Role r) => new(
+24 -6
View File
@@ -20,10 +20,12 @@ namespace ERPCore.Services;
/// rung up individually through the day with no GL impact of their own; <see cref="CloseAsync"/>
/// is the single point where that day's sales become real accounting entries — one consolidated
/// journal entry per cashier per day, mirroring how <see cref="GrnService"/> posts per receipt.
/// Every document is treated as a cash sale (Debit Cash for GrandTotal): SalesSlip's
/// PaidAmount/BalanceAmount fields exist for a future partial-payment flow but nothing
/// sets them today (SalesSlipService.CreateAsync always leaves PaidAmount at 0), so
/// they aren't a usable signal here — see docs/14-BACKEND-SALES-API.md if that changes.
/// Each slip's <see cref="SalesSlip.PaidAmount"/>/<see cref="SalesSlip.BalanceAmount"/> (set at
/// ring-up time, SalesSlipService.Recalculate) drive the GL split here: Cash is debited only for
/// what was actually collected (PaidTotal), and any remainder (BalanceTotal) goes to Accounts
/// Receivable instead — a slip fully paid at ring-up (the default when PaidAmount is omitted)
/// behaves exactly as before partial payment existed. Bundle sales have no PaidAmount field of
/// their own and are always counted as fully paid, same as every document was before this existed.
/// A bundle sale's net revenue is always exactly <c>BundlePrice</c> (<c>GrandTotal - TaxTotal</c>
/// by construction, docs/15) regardless of whether the bundle sold at a discount or a premium to
/// its component subtotal, so it folds into Subtotal/DiscountTotal the same way a slip's net
@@ -47,6 +49,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
private readonly string _glTaxPayableAccountCode;
private readonly string _glCogsAccountCode;
private readonly string _glInventoryAccountCode;
private readonly string _glAccountsReceivableCode;
public SalesDayEndService(
IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles,
@@ -69,6 +72,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
_glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
_glAccountsReceivableCode = configuration["SalesDayEnd:GlAccountsReceivableCode"] ?? string.Empty;
}
public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
@@ -92,6 +96,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
return new SalesDayEndPreviewDto(
cashierUserId, businessDate, true,
existing.SlipCount, existing.BundleCount, existing.Subtotal, existing.DiscountTotal, existing.TaxTotal, existing.GrandTotal,
existing.PaidTotal, existing.BalanceTotal,
closedSlipNumbers, closedBundleNumbers,
Array.Empty<DraftSlipBlockingCloseDto>(), Array.Empty<DraftBundleBlockingCloseDto>(), invoiceSummary);
}
@@ -108,10 +113,15 @@ public sealed class SalesDayEndService : ISalesDayEndService
var discountTotal = postedSlips.Sum(x => x.DiscountTotal);
var taxTotal = postedSlips.Sum(x => x.TaxTotal) + postedBundles.Sum(x => x.TaxTotal);
var grandTotal = postedSlips.Sum(x => x.GrandTotal) + postedBundles.Sum(x => x.GrandTotal);
// Bundle sales have no PaidAmount field — always counted fully paid, same as every
// document before partial payment existed.
var paidTotal = postedSlips.Sum(x => x.PaidAmount) + postedBundles.Sum(x => x.GrandTotal);
var balanceTotal = postedSlips.Sum(x => x.BalanceAmount);
return new SalesDayEndPreviewDto(
cashierUserId, businessDate, false,
postedSlips.Count, postedBundles.Count, subtotal, discountTotal, taxTotal, grandTotal,
paidTotal, balanceTotal,
postedSlips.OrderBy(x => x.SlipNo).Select(x => x.SlipNo).ToList(),
postedBundles.OrderBy(x => x.BundleNo).Select(x => x.BundleNo).ToList(),
draftSlips.OrderBy(x => x.SlipNo).Select(x => new DraftSlipBlockingCloseDto(x.SalesSlipId, x.SlipNo, x.GrandTotal)).ToList(),
@@ -155,6 +165,8 @@ public sealed class SalesDayEndService : ISalesDayEndService
var discountTotal = postedSlips.Sum(x => x.DiscountTotal);
var taxTotal = postedSlips.Sum(x => x.TaxTotal) + postedBundles.Sum(x => x.TaxTotal);
var grandTotal = postedSlips.Sum(x => x.GrandTotal) + postedBundles.Sum(x => x.GrandTotal);
var paidTotal = postedSlips.Sum(x => x.PaidAmount) + postedBundles.Sum(x => x.GrandTotal);
var balanceTotal = postedSlips.Sum(x => x.BalanceAmount);
var slipIds = postedSlips.Select(x => x.SalesSlipId).ToList();
var bundleIds = postedBundles.Select(x => x.BundleSaleId).ToList();
@@ -185,6 +197,8 @@ public sealed class SalesDayEndService : ISalesDayEndService
DiscountTotal = discountTotal,
TaxTotal = taxTotal,
GrandTotal = grandTotal,
PaidTotal = paidTotal,
BalanceTotal = balanceTotal,
CostOfGoodsSold = cogs,
ClosedBy = _currentUser.AuditUserId,
ClosedAt = now
@@ -196,7 +210,10 @@ public sealed class SalesDayEndService : ISalesDayEndService
var glLines = new List<GlJournalEntryLineRequest>();
if (grandTotal > 0)
{
glLines.Add(new GlJournalEntryLineRequest(_glCashAccountCode, grandTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected"));
if (paidTotal > 0)
glLines.Add(new GlJournalEntryLineRequest(_glCashAccountCode, paidTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected"));
if (balanceTotal > 0)
glLines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, balanceTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — sold on account"));
if (discountTotal > 0)
glLines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, discountTotal, 0m, "Sales discount"));
glLines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, subtotal, "Sales revenue"));
@@ -244,7 +261,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesDayEndId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
var items = rows.Select(x => new SalesDayEndSummaryDto(
x.SalesDayEndId, x.DocNo, x.CashierUserId, x.BusinessDate, x.SlipCount, x.BundleCount, x.GrandTotal, x.GlJournalNo, x.ClosedAt)).ToList();
x.SalesDayEndId, x.DocNo, x.CashierUserId, x.BusinessDate, x.SlipCount, x.BundleCount, x.GrandTotal, x.PaidTotal, x.BalanceTotal, x.GlJournalNo, x.ClosedAt)).ToList();
return PagedResponse<SalesDayEndSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
@@ -339,6 +356,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
return new SalesDayEndDto(
entity.SalesDayEndId, entity.DocNo, entity.CashierUserId, entity.BusinessDate,
entity.SlipCount, entity.BundleCount, entity.Subtotal, entity.DiscountTotal, entity.TaxTotal, entity.GrandTotal,
entity.PaidTotal, entity.BalanceTotal,
entity.CostOfGoodsSold, entity.GlJournalNo, entity.GlPostedAt, entity.ClosedBy, entity.ClosedAt,
slipNumbers, bundleNumbers, breakdown, invoiceSummary);
}
@@ -13,6 +13,7 @@ public sealed class SalesDomainService : ISalesDomainService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _users;
private readonly IRepository<Item> _items;
private readonly IRepository<TaxRate> _taxRates;
private readonly ISalesPricingService _pricing;
public SalesDomainService(
@@ -20,12 +21,14 @@ public sealed class SalesDomainService : ISalesDomainService
IRepository<Warehouse> warehouses,
IRepository<User> users,
IRepository<Item> items,
IRepository<TaxRate> taxRates,
ISalesPricingService pricing)
{
_customers = customers;
_warehouses = warehouses;
_users = users;
_items = items;
_taxRates = taxRates;
_pricing = pricing;
}
@@ -64,6 +67,22 @@ public sealed class SalesDomainService : ISalesDomainService
public Task<SalesPriceResolution> ResolveLinePriceAsync(int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default)
=> _pricing.ResolveAsync(itemId, warehouseId, requestedUnitPrice, allowManualOverride, ct);
public async Task<decimal> ResolveLineTaxPctAsync(int itemId, decimal? requestedTaxPct, CancellationToken ct = default)
{
if (requestedTaxPct is not null) return requestedTaxPct.Value;
var taxRateId = await _items.Query().AsNoTracking()
.Where(x => x.ItemId == itemId)
.Select(x => x.TaxRateId)
.FirstAsync(ct);
if (taxRateId is null) return 0m;
return await _taxRates.Query().AsNoTracking()
.Where(t => t.TaxRateId == taxRateId)
.Select(t => t.Percentage)
.FirstAsync(ct);
}
public SalesLineComputation ComputeLine(
decimal qty,
decimal freeQty,
@@ -164,8 +164,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
var unitPrice = resolved.UnitPrice;
var priceSource = resolved.PriceSource;
var taxPct = await _sales.ResolveLineTaxPctAsync(r.ItemId, r.TaxPct, ct);
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, taxPct, r.IsFreeIssue);
lines.Add(new SalesInvoiceLine
{
@@ -182,7 +183,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
DiscountMode = r.DiscountMode,
NetUnitPrice = calc.NetUnitPrice,
LineTotal = calc.LineTotal,
TaxPct = r.TaxPct,
TaxPct = taxPct,
TaxAmount = calc.TaxAmount,
IsFreeIssue = r.IsFreeIssue,
ParentLineId = r.ParentLineId
@@ -48,5 +48,6 @@ public sealed class SalesMappingService : ISalesMappingService
slip.Lines.Select(l => new SalesSlipLineDto(
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId,
l.BatchId, l.SerialId)).ToList());
}
@@ -19,6 +19,7 @@ public sealed class SalesPostingService : ISalesPostingService
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<Item> _items;
private readonly IRepository<Serial> _serials;
private readonly IFifoCostingService _fifo;
private readonly ISalesDomainService _sales;
private readonly ICurrentUser _currentUser;
@@ -36,6 +37,7 @@ public sealed class SalesPostingService : ISalesPostingService
IRepository<SalesSlip> slips,
IRepository<BundleSale> bundles,
IRepository<Item> items,
IRepository<Serial> serials,
IFifoCostingService fifo,
ISalesDomainService sales,
ICurrentUser currentUser,
@@ -47,6 +49,7 @@ public sealed class SalesPostingService : ISalesPostingService
_slips = slips;
_bundles = bundles;
_items = items;
_serials = serials;
_fifo = fifo;
_sales = sales;
_currentUser = currentUser;
@@ -157,7 +160,7 @@ public sealed class SalesPostingService : ISalesPostingService
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, null, null)),
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesInvoice,
@@ -214,7 +217,7 @@ public sealed class SalesPostingService : ISalesPostingService
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId, l.SerialId)),
setPosted: x => x.Status = SalesSlipStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesSlip,
@@ -227,7 +230,7 @@ public sealed class SalesPostingService : ISalesPostingService
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)),
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, null)),
setPosted: x => x.Status = BundleSaleStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.BundleSale,
@@ -268,11 +271,19 @@ public sealed class SalesPostingService : ISalesPostingService
// Line quantities are already a count of the item's base UOM — sales documents
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
totalCogs += cost * line.Qty;
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token);
// ConsumeAsync has no serial parameter — closing the loop on a serial-pinned
// line means flipping the serial's own status once its stock is actually gone.
if (line.SerialId.HasValue)
{
var serial = await _serials.Query().FirstOrDefaultAsync(s => s.SerialId == line.SerialId, token);
if (serial is not null) serial.Status = "Sold";
}
}
setPosted(doc);
@@ -282,5 +293,5 @@ public sealed class SalesPostingService : ISalesPostingService
}, ct);
}
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty, int? BatchId, int? SerialId);
}
+41 -10
View File
@@ -23,19 +23,23 @@ public sealed class SalesSlipService : ISalesSlipService
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _users;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly ISalesDomainService _sales;
private readonly ISalesPostingService _posting;
private readonly ISalesMappingService _mapping;
private readonly ISalesDocumentWorkflowService _workflow;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
public SalesSlipService(
IRepository<SalesSlip> slips, IRepository<SalesDayEnd> dayEnds, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, IRepository<Batch> batches, IRepository<Serial> serials,
ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
ICurrentUser currentUser, INumberSequenceService numbers, INotificationService notifications, IUnitOfWork uow)
{
_slips = slips;
_dayEnds = dayEnds;
@@ -44,12 +48,15 @@ public sealed class SalesSlipService : ISalesSlipService
_uoms = uoms;
_warehouses = warehouses;
_users = users;
_batches = batches;
_serials = serials;
_sales = sales;
_posting = posting;
_mapping = mapping;
_workflow = workflow;
_currentUser = currentUser;
_numbers = numbers;
_notifications = notifications;
_uow = uow;
}
@@ -135,11 +142,13 @@ public sealed class SalesSlipService : ISalesSlipService
CreatedAt = DateTime.UtcNow,
Lines = lines
};
Recalculate(entity);
Recalculate(entity, request.PaidAmount);
await _slips.AddAsync(entity, token);
return entity;
}, ct);
await _notifications.RaiseAsync("sale.create", "Sale Created", $"Sales slip {slip.SlipNo} was created.", nameof(SalesSlip), slip.SalesSlipId, ct);
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
}
@@ -154,7 +163,7 @@ public sealed class SalesSlipService : ISalesSlipService
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
slip.Lines.Clear();
foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line);
Recalculate(slip);
Recalculate(slip, request.PaidAmount);
slip.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
@@ -165,6 +174,7 @@ public sealed class SalesSlipService : ISalesSlipService
await _posting.PostSlipAsync(salesSlipId, ct);
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.SalesSlipId == salesSlipId, ct);
await _notifications.RaiseAsync("sale.posted", "Sale Posted", $"Sales slip {slip.SlipNo} was posted.", nameof(SalesSlip), slip.SalesSlipId, ct);
return _mapping.MapSlip(slip);
}
@@ -190,8 +200,14 @@ public sealed class SalesSlipService : ISalesSlipService
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
var unitPrice = resolved.UnitPrice;
var priceSource = resolved.PriceSource;
var taxPct = await _sales.ResolveLineTaxPctAsync(r.ItemId, r.TaxPct, ct);
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, taxPct, r.IsFreeIssue);
if (r.BatchId is not null && !await _batches.Query().AnyAsync(b => b.BatchId == r.BatchId && b.ItemId == r.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Batch {r.BatchId} does not belong to item {r.ItemId}.", 422);
if (r.SerialId is not null && !await _serials.Query().AnyAsync(s => s.SerialId == r.SerialId && s.ItemId == r.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Serial {r.SerialId} does not belong to item {r.ItemId}.", 422);
lines.Add(new SalesSlipLine
{
@@ -200,6 +216,8 @@ public sealed class SalesSlipService : ISalesSlipService
Qty = r.Qty,
FreeQty = r.FreeQty,
WarehouseId = r.WarehouseId,
BatchId = r.BatchId,
SerialId = r.SerialId,
UnitPrice = unitPrice,
BaseCost = unitPrice,
PriceSource = priceSource,
@@ -208,7 +226,7 @@ public sealed class SalesSlipService : ISalesSlipService
DiscountAmount = calc.DiscountTotal,
NetUnitPrice = calc.NetUnitPrice,
LineTotal = calc.LineTotal,
TaxPct = r.TaxPct,
TaxPct = taxPct,
TaxAmount = calc.TaxAmount,
IsFreeIssue = r.IsFreeIssue,
ParentLineId = r.ParentLineId
@@ -217,14 +235,27 @@ public sealed class SalesSlipService : ISalesSlipService
return lines;
}
private static void Recalculate(SalesSlip slip)
/// <summary>
/// <paramref name="requestedPaidAmount"/> null defaults to fully paid (PaidAmount ==
/// GrandTotal, BalanceAmount == 0) — the behavior every slip had before partial payment
/// existed, so omitting it is a no-op change. Must be clamped against the just-recomputed
/// GrandTotal, not the request's own line totals, since GrandTotal is only known after
/// lines are summed here.
/// </summary>
private static void Recalculate(SalesSlip slip, decimal? requestedPaidAmount)
{
slip.Subtotal = slip.Lines.Sum(x => x.Qty * x.UnitPrice);
slip.DiscountTotal = slip.Lines.Sum(x => x.DiscountAmount);
slip.TaxTotal = slip.Lines.Sum(x => x.TaxAmount);
slip.GrandTotal = slip.Lines.Sum(x => x.LineTotal) + slip.TaxTotal;
slip.PaidAmount = 0m;
slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount;
var paid = requestedPaidAmount ?? slip.GrandTotal;
if (paid < 0 || paid > slip.GrandTotal)
throw new DomainException(ErrorCodes.SalesSlipPaidAmountInvalid,
$"paidAmount must be between 0 and the slip's total {slip.GrandTotal}.", 400);
slip.PaidAmount = paid;
slip.BalanceAmount = slip.GrandTotal - paid;
}
private SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
@@ -280,7 +311,7 @@ public sealed class SalesSlipService : ISalesSlipService
x.CreatedAt,
x.UpdatedAt,
summary,
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId, l.BatchId, l.SerialId)).ToList());
}
private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x);
@@ -3,6 +3,7 @@ using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
@@ -19,23 +20,26 @@ public sealed class FifoCostingService : IFifoCostingService
public const string BaseCurrency = "LKR";
public const string Method = "FIFO";
// Phase-1 placeholder GL accounts (data only, no posting — FR-STK-13).
private const string InventoryAccount = "1300";
private const string ClearingAccount = "2100";
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<JournalEntryStub> _journal;
private readonly ErpDbContext _db;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
private readonly string _glClearingAccountCode;
public FifoCostingService(
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IRepository<JournalEntryStub> journal, ErpDbContext db)
IRepository<JournalEntryStub> journal, ErpDbContext db,
IGeneralLedgerService gl, IConfiguration configuration)
{
_layers = layers;
_ledger = ledger;
_journal = journal;
_db = db;
_gl = gl;
_glInventoryAccountCode = configuration["Fifo:GlInventoryAccountCode"] ?? string.Empty;
_glClearingAccountCode = configuration["Fifo:GlClearingAccountCode"] ?? string.Empty;
}
public async Task<StockLayer> CreateInboundLayerAsync(
@@ -73,8 +77,10 @@ public sealed class FifoCostingService : IFifoCostingService
}
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
// Excludes on-hold (grn line) and expired-batch stock. No LINQ is composed on
// top of the raw SQL so the FOR UPDATE reaches the database intact.
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
// sits quarantined until a Purchase Return consumes it directly (see
// PurchaseReturnService), never through this general-issue path. No LINQ is
// composed on top of the raw SQL so the FOR UPDATE reaches the database intact.
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
// parameter — without it a null batch filter fails with 42P18.
var layers = await _db.StockLayers
@@ -82,7 +88,7 @@ public sealed class FifoCostingService : IFifoCostingService
SELECT * FROM stock_layers sl
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" = 'OnHold')
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})
ORDER BY sl."ReceiptDate", sl."LayerId"
FOR UPDATE
@@ -95,7 +101,7 @@ public sealed class FifoCostingService : IFifoCostingService
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
&& (batchId == null || l.BatchId == batchId)
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold, ct);
&& l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected), ct);
if (heldExists)
throw new DomainException(ErrorCodes.OnHoldNotIssuable,
$"Stock for item {itemId} at warehouse {warehouseId} is on inspection hold and cannot be issued.", 409);
@@ -145,11 +151,12 @@ public sealed class FifoCostingService : IFifoCostingService
};
await _ledger.AddAsync(entry, ct);
// GL-ready journal entry per movement (FR-STK-13; data only, no posting).
// Inbound debits Inventory / credits Clearing; outbound reverses.
var (debit, credit) = direction == Direction.In
? (InventoryAccount, ClearingAccount)
: (ClearingAccount, InventoryAccount);
? (_glInventoryAccountCode, _glClearingAccountCode)
: (_glClearingAccountCode, _glInventoryAccountCode);
// Local read-only stub kept for the journal-entries listing endpoint (FR-STK-13).
await _journal.AddAsync(new JournalEntryStub
{
SourceDocType = sourceDocType,
@@ -159,6 +166,24 @@ public sealed class FifoCostingService : IFifoCostingService
Amount = entry.Value
}, ct);
if (entry.Value != 0)
{
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(createdAt), ct);
await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(createdAt),
SourceModule = "STOCK",
Reference = $"{sourceDocType}#{sourceDocId}",
Narration = $"{sourceDocType} {sourceDocId} — {direction} {qtyBase} @ {unitCost}",
Lines = new List<GlJournalEntryLineRequest>
{
new(debit, entry.Value, 0m),
new(credit, 0m, entry.Value)
}
}, ct);
}
return entry;
}
@@ -42,10 +42,12 @@ public sealed class StockService : IStockService
{
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
// On-hold stock is on-hand but not issuable — sourced from GRN lines still OnHold.
// On-hold stock is on-hand but not issuable — GRN lines still OnHold (pending
// inspection) or Rejected (quarantined pending a Purchase Return); both are
// excluded from FifoCostingService.ConsumeAsync the same way.
var onHold = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold)
&& l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected))
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
// Outbound in-transit: dispatched from this warehouse, not yet received at the
@@ -99,7 +101,7 @@ public sealed class StockService : IStockService
var onHold = (await _layers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId)
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold)
&& l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected))
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
.ToListAsync(ct))
@@ -0,0 +1,207 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Support;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class SupportTicketService : ISupportTicketService
{
private readonly IRepository<SupportTicket> _tickets;
private readonly IRepository<SupportTicketMessage> _messages;
private readonly IRepository<User> _users;
private readonly IUnitOfWork _uow;
public SupportTicketService(
IRepository<SupportTicket> tickets,
IRepository<SupportTicketMessage> messages,
IRepository<User> users,
IUnitOfWork uow)
{
_tickets = tickets;
_messages = messages;
_users = users;
_uow = uow;
}
public async Task<ETagged<SupportTicketDto>> CreateAsync(int userId, CreateSupportTicketRequest request, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var ticket = new SupportTicket
{
Subject = request.Subject.Trim(),
Status = TicketStatus.Open,
CreatedBy = userId,
CreatedAt = now,
};
ticket.Messages.Add(new SupportTicketMessage
{
SenderUserId = userId,
IsFromAdmin = false,
Message = request.Message.Trim(),
CreatedAt = now,
});
await _tickets.AddAsync(ticket, ct);
await _uow.SaveChangesAsync(ct);
return await GetByIdAsync(ticket.TicketId, userId, isAdmin: true, ct);
}
public async Task<IReadOnlyList<SupportTicketSummaryDto>> GetListAsync(int userId, bool isAdmin, CancellationToken ct = default)
{
var query = _tickets.Query().AsNoTracking();
if (!isAdmin) query = query.Where(t => t.CreatedBy == userId);
var tickets = await query
.OrderByDescending(t => t.UpdatedAt ?? t.CreatedAt)
.Select(t => new
{
t.TicketId,
t.Subject,
t.Status,
t.CreatedBy,
t.CreatedAt,
t.UpdatedBy,
t.UpdatedAt,
MessageCount = t.Messages.Count,
})
.ToListAsync(ct);
var names = await ResolveNamesAsync(tickets.Select(t => t.CreatedBy), ct);
return tickets
.Select(t => new SupportTicketSummaryDto(
t.TicketId,
t.Subject,
t.Status,
t.CreatedBy,
names.GetValueOrDefault(t.CreatedBy, "Unknown"),
t.CreatedAt,
t.UpdatedBy,
t.UpdatedAt,
t.MessageCount))
.ToList();
}
public async Task<ETagged<SupportTicketDto>> GetByIdAsync(int ticketId, int userId, bool isAdmin, CancellationToken ct = default)
{
var ticket = await _tickets.Query().AsNoTracking()
.Include(t => t.Messages)
.FirstOrDefaultAsync(t => t.TicketId == ticketId, ct)
?? throw new NotFoundException($"Support ticket {ticketId} was not found.");
if (!isAdmin && ticket.CreatedBy != userId)
throw new DomainException("FORBIDDEN", "You do not have access to this ticket.", 403);
var names = await ResolveNamesAsync(
ticket.Messages.Select(m => m.SenderUserId)
.Append(ticket.CreatedBy)
.Concat(ticket.UpdatedBy is int updatedBy ? new[] { updatedBy } : Array.Empty<int>()),
ct);
var messages = ticket.Messages
.OrderBy(m => m.CreatedAt)
.Select(m => new SupportTicketMessageDto(
m.MessageId,
m.SenderUserId,
names.GetValueOrDefault(m.SenderUserId, "Unknown"),
m.IsFromAdmin,
m.Message,
m.CreatedAt))
.ToList();
var dto = new SupportTicketDto(
ticket.TicketId,
ticket.Subject,
ticket.Status,
ticket.CreatedBy,
names.GetValueOrDefault(ticket.CreatedBy, "Unknown"),
ticket.CreatedAt,
ticket.UpdatedBy,
ticket.UpdatedBy is int u ? names.GetValueOrDefault(u, "Unknown") : null,
ticket.UpdatedAt,
messages);
return new ETagged<SupportTicketDto>(dto, ticket.RowVersion);
}
public async Task<ETagged<SupportTicketDto>> AddMessageAsync(int ticketId, int userId, bool isAdmin, AddSupportTicketMessageRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var ticket = await _tickets.Query()
.FirstOrDefaultAsync(t => t.TicketId == ticketId, ct)
?? throw new NotFoundException($"Support ticket {ticketId} was not found.");
if (!isAdmin && ticket.CreatedBy != userId)
throw new DomainException("FORBIDDEN", "You do not have access to this ticket.", 403);
if (ticket.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "This ticket was modified by another request.", 412);
var now = DateTime.UtcNow;
await _messages.AddAsync(new SupportTicketMessage
{
TicketId = ticketId,
SenderUserId = userId,
IsFromAdmin = isAdmin,
Message = request.Message.Trim(),
CreatedAt = now,
}, ct);
ticket.UpdatedBy = userId;
ticket.UpdatedAt = now;
if (isAdmin && ticket.Status == TicketStatus.Open) ticket.Status = TicketStatus.InProgress;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "This ticket was modified by another request.", 412);
}
return await GetByIdAsync(ticketId, userId, isAdmin, ct);
}
public async Task<ETagged<SupportTicketDto>> UpdateStatusAsync(int ticketId, int userId, bool isAdmin, UpdateSupportTicketStatusRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
if (!isAdmin)
throw new DomainException("FORBIDDEN", "Only an administrator can change a ticket's status.", 403);
var ticket = await _tickets.Query()
.FirstOrDefaultAsync(t => t.TicketId == ticketId, ct)
?? throw new NotFoundException($"Support ticket {ticketId} was not found.");
if (ticket.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "This ticket was modified by another request.", 412);
ticket.Status = request.Status;
ticket.UpdatedBy = userId;
ticket.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "This ticket was modified by another request.", 412);
}
return await GetByIdAsync(ticketId, ticket.CreatedBy, isAdmin: true, ct);
}
private async Task<Dictionary<int, string>> ResolveNamesAsync(IEnumerable<int> userIds, CancellationToken ct)
{
var ids = userIds.Distinct().ToList();
return await _users.Query().AsNoTracking()
.Where(u => ids.Contains(u.UserId))
.ToDictionaryAsync(u => u.UserId, u => u.DisplayName, ct);
}
}
+117
View File
@@ -0,0 +1,117 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.TaxRates;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>Tax rate master service — see <see cref="TaxRate"/>.</summary>
public sealed class TaxRateService : ITaxRateService
{
private readonly IRepository<TaxRate> _taxRates;
private readonly IUnitOfWork _uow;
public TaxRateService(IRepository<TaxRate> taxRates, IUnitOfWork uow)
{
_taxRates = taxRates;
_uow = uow;
}
public async Task<PagedResponse<TaxRateDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _taxRates.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.Code, $"%{term}%") || EF.Functions.ILike(t.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(t => t.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new TaxRateDto(t.TaxRateId, t.Code, t.Name, t.Percentage, t.Status, t.CreatedAt, t.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<TaxRateDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<TaxRateDto>?> GetAsync(int taxRateId, CancellationToken ct = default)
{
var rate = await _taxRates.Query().AsNoTracking().FirstOrDefaultAsync(t => t.TaxRateId == taxRateId, ct);
return rate is null ? null : new ETagged<TaxRateDto>(Map(rate), rate.RowVersion);
}
public async Task<ETagged<TaxRateDto>> CreateAsync(CreateTaxRateRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim().ToUpperInvariant();
if (await _taxRates.Query().AnyAsync(t => t.Code == code, ct))
throw new ConflictException($"A tax rate with code '{code}' already exists.");
var rate = new TaxRate
{
Code = code,
Name = request.Name.Trim(),
Percentage = request.Percentage,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _taxRates.AddAsync(rate, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<TaxRateDto>(Map(rate), rate.RowVersion);
}
public async Task<ETagged<TaxRateDto>> UpdateAsync(
int taxRateId, UpdateTaxRateRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var rate = await _taxRates.GetByIdAsync(taxRateId, ct)
?? throw new NotFoundException($"Tax rate {taxRateId} was not found.");
if (rate.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The tax rate was modified by another request.", 412);
var code = request.Code.Trim().ToUpperInvariant();
if (!string.Equals(rate.Code, code, StringComparison.Ordinal)
&& await _taxRates.Query().AnyAsync(t => t.Code == code && t.TaxRateId != taxRateId, ct))
throw new ConflictException($"A tax rate with code '{code}' already exists.");
// Existing lines never recompute from this — SalesSlipLine.TaxPct etc. are snapshots
// taken at line-creation time — so changing Percentage here only affects documents
// created from now on, never rewrites history.
rate.Code = code;
rate.Name = request.Name.Trim();
rate.Percentage = request.Percentage;
rate.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The tax rate was modified by another request.", 412);
}
return new ETagged<TaxRateDto>(Map(rate), rate.RowVersion);
}
public async Task SetStatusAsync(int taxRateId, EntityStatus status, CancellationToken ct = default)
{
var rate = await _taxRates.GetByIdAsync(taxRateId, ct)
?? throw new NotFoundException($"Tax rate {taxRateId} was not found.");
rate.Status = status;
rate.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static TaxRateDto Map(TaxRate t) => new(t.TaxRateId, t.Code, t.Name, t.Percentage, t.Status, t.CreatedAt, t.UpdatedAt);
}
+5 -1
View File
@@ -35,12 +35,13 @@ public sealed class TransferService : ITransferService
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
public TransferService(
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser,
IBranchConfigService branchConfig, IUnitOfWork uow)
IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow)
{
_transfers = transfers;
_warehouses = warehouses;
@@ -49,6 +50,7 @@ public sealed class TransferService : ITransferService
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_notifications = notifications;
_uow = uow;
}
@@ -123,6 +125,8 @@ public sealed class TransferService : ITransferService
return entity;
}, ct);
await _notifications.RaiseAsync("stock.transfer.created", "Stock Transfer Created", $"Stock transfer {transfer.DocNo} was created.", nameof(StockTransfer), transfer.TransferId, ct);
return Map(transfer);
}
@@ -189,6 +189,15 @@ public sealed class UserManagementService : IUserManagementService
return userTypes.Select(t => new UserTypeOptionDto(t.UserTypeId, t.Code, t.Description)).ToList();
}
public Task<DeleteAuthHexUserResult> PurgeAuthHexIdentityAsync(PurgeAuthHexIdentityRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.Email) && string.IsNullOrWhiteSpace(request.MobileNumber)
&& string.IsNullOrWhiteSpace(request.Nic) && string.IsNullOrWhiteSpace(request.UserName))
throw new DomainException(ErrorCodes.Validation, "At least one identifier (email, mobileNumber, nic, userName) is required.", 422);
return _authHex.DeleteUserAsync(request.Email, request.MobileNumber, request.Nic, request.UserName, ct);
}
private static ManagedUserDto Map(User u) => new(
u.UserId, u.Username, u.DisplayName, u.Email, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name);
}
+9 -2
View File
@@ -14,11 +14,13 @@ namespace ERPCore.Services;
public sealed class VendorService : IVendorService
{
private readonly IRepository<Vendor> _vendors;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public VendorService(IRepository<Vendor> vendors, IUnitOfWork uow)
public VendorService(IRepository<Vendor> vendors, IBranchConfigService branchConfig, IUnitOfWork uow)
{
_vendors = vendors;
_branchConfig = branchConfig;
_uow = uow;
}
@@ -53,6 +55,7 @@ public sealed class VendorService : IVendorService
if (await _vendors.Query().AnyAsync(v => v.Code == code, ct))
throw new ConflictException($"A vendor with code '{code}' already exists.");
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var vendor = new Vendor
{
Code = code,
@@ -61,6 +64,7 @@ public sealed class VendorService : IVendorService
TaxReg = request.TaxReg,
Currency = request.Currency.Trim().ToUpperInvariant(),
Status = EntityStatus.Active,
BranchId = branchId,
CreatedAt = DateTime.UtcNow
};
@@ -84,11 +88,14 @@ public sealed class VendorService : IVendorService
&& await _vendors.Query().AnyAsync(v => v.Code == code && v.VendorId != vendorId, ct))
throw new ConflictException($"A vendor with code '{code}' already exists.");
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
vendor.Code = code;
vendor.Name = request.Name.Trim();
vendor.Terms = request.Terms;
vendor.TaxReg = request.TaxReg;
vendor.Currency = request.Currency.Trim().ToUpperInvariant();
vendor.BranchId = branchId;
vendor.UpdatedAt = DateTime.UtcNow;
try
@@ -114,5 +121,5 @@ public sealed class VendorService : IVendorService
}
private static VendorDto Map(Vendor v) => new(
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.CreatedAt, v.UpdatedAt);
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
}
+51 -5
View File
@@ -41,7 +41,7 @@ public sealed class WarehouseService : IWarehouseService
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code)
.Skip(query.Skip).Take(query.PageSize)
.Select(w => new WarehouseDto(w.WarehouseId, w.Code, w.Name, w.BranchId, w.Branch != null ? w.Branch.Name : null))
.Select(w => new WarehouseDto(w.WarehouseId, w.Code, w.Name, w.Address, w.Phone, w.BranchId, w.Branch != null ? w.Branch.Name : null))
.ToListAsync(ct);
return PagedResponse<WarehouseDto>.Create(rows, query.Page, query.PageSize, total);
@@ -51,7 +51,7 @@ public sealed class WarehouseService : IWarehouseService
{
var w = await _warehouses.Query().AsNoTracking().Include(x => x.Branch)
.FirstOrDefaultAsync(x => x.WarehouseId == warehouseId, ct);
return w is null ? null : new WarehouseDto(w.WarehouseId, w.Code, w.Name, w.BranchId, w.Branch?.Name);
return w is null ? null : new WarehouseDto(w.WarehouseId, w.Code, w.Name, w.Address, w.Phone, w.BranchId, w.Branch?.Name);
}
public async Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default)
@@ -60,14 +60,60 @@ public sealed class WarehouseService : IWarehouseService
if (await _warehouses.Query().AnyAsync(w => w.Code == code, ct))
throw new ConflictException($"A warehouse with code '{code}' already exists.");
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var warehouse = new Warehouse { Code = code, Name = request.Name.Trim(), BranchId = branchId };
var branchId = await _branchConfig.ResolveWarehouseBranchIdAsync(request.BranchId, ct);
var warehouse = new Warehouse
{
Code = code,
Name = request.Name.Trim(),
Address = string.IsNullOrWhiteSpace(request.Address) ? null : request.Address.Trim(),
Phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim(),
BranchId = branchId,
};
await _warehouses.AddAsync(warehouse, ct);
await _uow.SaveChangesAsync(ct);
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);
return new WarehouseDto(warehouse.WarehouseId, warehouse.Code, warehouse.Name, warehouse.Address, warehouse.Phone, warehouse.BranchId, branchName);
}
public async Task<WarehouseDto> UpdateAsync(int warehouseId, UpdateWarehouseRequest request, CancellationToken ct = default)
{
var warehouse = await _warehouses.GetByIdAsync(warehouseId, ct)
?? throw new NotFoundException($"Warehouse {warehouseId} was not found.");
var code = request.Code.Trim();
if (await _warehouses.Query().AnyAsync(w => w.WarehouseId != warehouseId && w.Code == code, ct))
throw new ConflictException($"A warehouse with code '{code}' already exists.");
var branchId = await _branchConfig.ResolveWarehouseBranchIdAsync(request.BranchId, ct);
warehouse.Code = code;
warehouse.Name = request.Name.Trim();
warehouse.Address = string.IsNullOrWhiteSpace(request.Address) ? null : request.Address.Trim();
warehouse.Phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
warehouse.BranchId = branchId;
await _uow.SaveChangesAsync(ct);
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.Address, warehouse.Phone, warehouse.BranchId, branchName);
}
public async Task DeleteAsync(int warehouseId, CancellationToken ct = default)
{
var warehouse = await _warehouses.GetByIdAsync(warehouseId, ct)
?? throw new NotFoundException($"Warehouse {warehouseId} was not found.");
_warehouses.Remove(warehouse);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This warehouse is referenced by other records (bins, stock, or documents) and cannot be deleted.", 409);
}
}
public async Task<IReadOnlyList<BinDto>> ListBinsAsync(int warehouseId, CancellationToken ct = default)