diff --git a/Backend/ERPCore.Tests/CustomerFieldSwitchTests.cs b/Backend/ERPCore.Tests/CustomerFieldSwitchTests.cs index 17b9e48..392d81e 100644 --- a/Backend/ERPCore.Tests/CustomerFieldSwitchTests.cs +++ b/Backend/ERPCore.Tests/CustomerFieldSwitchTests.cs @@ -16,9 +16,7 @@ namespace ERPCore.Tests; public class CustomerFieldSwitchTests { private static CustomerService Svc(ErpDbContext db) => - new(new Repository(db), new Repository(db), new FakeBranchConfig(), - new Repository(db), new Repository(db), new Repository(db), - new PassThroughUnitOfWork(db)); + new(new CustomerRepository(db), new FakeBranchConfig(), new PassThroughUnitOfWork(db)); private static async Task SwitchesAsync(ErpDbContext db, Action tweak) { diff --git a/Backend/ERPCore.Tests/DayEndReportServiceTests.cs b/Backend/ERPCore.Tests/DayEndReportServiceTests.cs index bf9613c..0cd272b 100644 --- a/Backend/ERPCore.Tests/DayEndReportServiceTests.cs +++ b/Backend/ERPCore.Tests/DayEndReportServiceTests.cs @@ -237,13 +237,8 @@ public class DayEndReportServiceTests .Build(); var clock = new BusinessDayClock(config); var branch = new CurrentBranchContext(new TestCurrentUser()); - var cash = new CashInHandService( - new Repository(db), new Repository(db), new Repository(db), - new Repository(db), new Repository(db), branch, clock, config); - return new DayEndReportService( - new Repository(db), new Repository(db), new Repository(db), - new Repository(db), new Repository(db), - new Repository(db), new Repository(db), cash, branch, clock); + var cash = new CashInHandService(new CashInHandRepository(db), branch, clock, config); + return new DayEndReportService(new DayEndReportRepository(db), cash, branch, clock); } private static ErpDbContext CreateContext() diff --git a/Backend/ERPCore.Tests/DualApprovalTests.cs b/Backend/ERPCore.Tests/DualApprovalTests.cs index dc9953c..dd88fc0 100644 --- a/Backend/ERPCore.Tests/DualApprovalTests.cs +++ b/Backend/ERPCore.Tests/DualApprovalTests.cs @@ -396,10 +396,7 @@ public class DualApprovalTests (GlAccountRole.InventoryLoss, "5900")), new SerialTrackingService(new Repository(db), new Repository(db), new Repository(db), new Repository(db), user)); - var prices = new PriceAdjustmentService( - new Repository(db), new Repository(db), new Repository(db), - new Repository(db), new Repository(db), new Repository(db), - approvals, notifications, numbers, user, uow); + var prices = new PriceAdjustmentService(new PriceAdjustmentRepository(db), approvals, notifications, numbers, user, uow); return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices }; } diff --git a/Backend/ERPCore.Tests/ProcurementAndOpeningBalanceTests.cs b/Backend/ERPCore.Tests/ProcurementAndOpeningBalanceTests.cs index 9c40f7b..936bbb3 100644 --- a/Backend/ERPCore.Tests/ProcurementAndOpeningBalanceTests.cs +++ b/Backend/ERPCore.Tests/ProcurementAndOpeningBalanceTests.cs @@ -45,7 +45,7 @@ public class ProcurementAndOpeningBalanceTests } private static ProcurementConfigService ConfigService(ErpDbContext db) => - new(new Repository(db), new TestCurrentUser(), new PassThroughUnitOfWork(db)); + new(new ProcurementConfigRepository(db), new TestCurrentUser(), new PassThroughUnitOfWork(db)); private static VendorService VendorSvc(ErpDbContext db) => new(new Repository(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db)); @@ -214,8 +214,7 @@ public class ProcurementAndOpeningBalanceTests // ---------------------------------------------------------------- purchase-order tax private static PurchaseOrderService PoSvc(ErpDbContext db) => - new(new Repository(db), new Repository(db), new Repository(db), - new Repository(db), new Repository(db), new Repository(db), + new(new PurchaseOrderRepository(db), new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db)); @@ -561,9 +560,7 @@ public class ProcurementAndOpeningBalanceTests var fifo = new FifoCostingService( new Repository(db), new Repository(db), new Repository(db), db, gl, glAccounts); return new OpeningStockService( - new Repository(db), new Repository(db), new Repository(db), - new Repository(db), new Repository(db), fifo, gl, glAccounts, - new TestCurrentUser(), new PassThroughUnitOfWork(db)); + new OpeningStockRepository(db), fifo, gl, glAccounts, new TestCurrentUser(), new PassThroughUnitOfWork(db)); } private static Item StockedItem(int id, Action? tweak = null) diff --git a/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs b/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs index e56526c..db67e16 100644 --- a/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs +++ b/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs @@ -145,9 +145,7 @@ internal static class BankDepositTestSupport } public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart) - => new(new Repository(db), new Repository(db), new Repository(db), - new Repository(db), new Repository(db), - new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart)); + => new(new CashInHandRepository(db), new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart)); public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl) { diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index fdfcdd8..e49a017 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -116,6 +116,30 @@ builder.Services.AddScoped(); builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); +// Aggregate-specific repositories (bespoke queries on top of the generic base) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Master-data services (docs/11 §2) builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Repositories/BrandRepository.cs b/Backend/ERPCore/Repositories/BrandRepository.cs new file mode 100644 index 0000000..e452174 --- /dev/null +++ b/Backend/ERPCore/Repositories/BrandRepository.cs @@ -0,0 +1,38 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class BrandRepository : Repository, IBrandRepository +{ + public BrandRepository(ErpDbContext db) : base(db) { } + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default) + { + var q = Set.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(b => EF.Functions.Like(b.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(b => b.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(b => b.Name).Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public Task GetForReadAsync(int brandId, CancellationToken ct = default) + => Set.AsNoTracking().FirstOrDefaultAsync(b => b.BrandId == brandId, ct); + + public Task NameExistsAsync(string name, int? excludeBrandId = null, CancellationToken ct = default) + { + var lowered = name.ToLower(); + return Set.AnyAsync(b => b.Name.ToLower() == lowered && (excludeBrandId == null || b.BrandId != excludeBrandId), ct); + } +} diff --git a/Backend/ERPCore/Repositories/BundleSaleRepository.cs b/Backend/ERPCore/Repositories/BundleSaleRepository.cs new file mode 100644 index 0000000..4cc2d82 --- /dev/null +++ b/Backend/ERPCore/Repositories/BundleSaleRepository.cs @@ -0,0 +1,85 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class BundleSaleRepository : Repository, IBundleSaleRepository +{ + public BundleSaleRepository(ErpDbContext db) : base(db) { } + + private DbSet Templates => Db.Set(); + + // --- Templates ------------------------------------------------------------------------------- + + public async Task<(IReadOnlyList Rows, int Total)> ListTemplatesPagedAsync( + string? search, int skip, int take, CancellationToken ct = default) + { + IQueryable q = Templates.AsNoTracking().Include(x => x.Lines); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(x => EF.Functions.Like(x.TemplateCode, $"%{term}%") + || EF.Functions.Like(x.TemplateName, $"%{term}%") + || EF.Functions.Like(x.Description ?? "", $"%{term}%")); + } + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public Task GetTemplateForReadAsync(int bundleSaleTemplateId, CancellationToken ct = default) + => Templates.AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleTemplateId == bundleSaleTemplateId, ct); + + public async Task AddTemplateAsync(BundleSaleTemplate template, CancellationToken ct = default) + => await Templates.AddAsync(template, ct); + + // --- Bundle sales ---------------------------------------------------------------------------- + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, BundleSaleStatus? status, int? customerId, int? warehouseId, int skip, int take, CancellationToken ct = default) + { + IQueryable q = Set.AsNoTracking().Include(x => x.Lines); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(x => EF.Functions.Like(x.BundleNo, $"%{term}%") + || EF.Functions.Like(x.BundleName, $"%{term}%") + || EF.Functions.Like(x.BundleCode, $"%{term}%")); + } + if (status is not null) q = q.Where(x => x.Status == status); + if (customerId is not null) q = q.Where(x => x.CustomerId == customerId); + if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.BundleSaleId).Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public Task GetForReadAsync(int bundleSaleId, CancellationToken ct = default) + => Set.AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct); + + public Task GetForUpdateAsync(int bundleSaleId, CancellationToken ct = default) + => Set.Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct); + + // --- Lookups --------------------------------------------------------------------------------- + + public Task IsDayClosedAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default) + => Db.Set().AsNoTracking().AnyAsync(x => x.CashierUserId == cashierUserId && x.BusinessDate == businessDate, ct); + + public Task GetCustomerSnapshotNameAsync(int customerId, CancellationToken ct = default) + => Db.Set().AsNoTracking().Where(x => x.CustomerId == customerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + + public Task ItemExistsAsync(int itemId, CancellationToken ct = default) + => Db.Set().AsNoTracking().AnyAsync(x => x.ItemId == itemId, ct); + + public Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default) + => Db.Set().AsNoTracking().AnyAsync(x => x.WarehouseId == warehouseId, ct); + + public Task GetItemNameAsync(int itemId, CancellationToken ct = default) + => Db.Set().AsNoTracking().Where(x => x.ItemId == itemId).Select(x => x.Name).FirstAsync(ct); +} diff --git a/Backend/ERPCore/Repositories/CashInHandRepository.cs b/Backend/ERPCore/Repositories/CashInHandRepository.cs new file mode 100644 index 0000000..e71d11a --- /dev/null +++ b/Backend/ERPCore/Repositories/CashInHandRepository.cs @@ -0,0 +1,90 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . Every query is read-only (no tracking). +public sealed class CashInHandRepository : ICashInHandRepository +{ + private readonly ErpDbContext _db; + + public CashInHandRepository(ErpDbContext db) => _db = db; + + public async Task SumCashInAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default) + => await Slips(lo, hi, branchId).SumAsync(s => s.CashAmount, ct) + + await Bundles(lo, hi, branchId).SumAsync(b => b.CashAmount, ct) + + await InvoicePayments(lo, hi, branchId).SumAsync(p => p.Amount, ct); + + public Task SumCashOutAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default) + => ExpensePayments(lo, hi, branchId).SumAsync(p => p.Amount, ct); + + public Task SumDepositsAsync(DateOnly? lo, DateOnly hi, int? branchId, CancellationToken ct = default) + => Deposits(lo, hi, branchId).SumAsync(d => d.Amount, ct); + + public async Task> ListCashInAsync(DateTime lo, DateTime hi, int? branchId, CancellationToken ct = default) + { + var rows = new List<(DateTime At, decimal Amount)>(); + rows.AddRange((await Slips(lo, hi, branchId).Select(s => new { s.SlipDate, s.CashAmount }).ToListAsync(ct)) + .Select(r => (r.SlipDate, r.CashAmount))); + rows.AddRange((await Bundles(lo, hi, branchId).Select(b => new { b.BundleDate, b.CashAmount }).ToListAsync(ct)) + .Select(r => (r.BundleDate, r.CashAmount))); + rows.AddRange((await InvoicePayments(lo, hi, branchId).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct)) + .Select(r => (r.PaymentDate, r.Amount))); + return rows; + } + + public async Task> ListCashOutAsync(DateTime lo, DateTime hi, int? branchId, CancellationToken ct = default) + => (await ExpensePayments(lo, hi, branchId).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct)) + .Select(r => (r.PaymentDate, r.Amount)).ToList(); + + public async Task> ListDepositsAsync(DateOnly lo, DateOnly hi, int? branchId, CancellationToken ct = default) + => (await Deposits(lo, hi, branchId).Select(d => new { d.BusinessDate, d.Amount }).ToListAsync(ct)) + .Select(r => (r.BusinessDate, r.Amount)).ToList(); + + // --- Sources. lo is inclusive (null = no lower bound), hi exclusive. --- + + private IQueryable Slips(DateTime? lo, DateTime hi, int? branch) + { + var q = _db.Set().AsNoTracking() + .Where(s => s.Status == SalesSlipStatus.Posted && s.CashAmount != 0 && s.SlipDate < hi); + if (lo is not null) q = q.Where(s => s.SlipDate >= lo); + return branch is null ? q : q.Where(s => s.Warehouse!.BranchId == branch); + } + + private IQueryable Bundles(DateTime? lo, DateTime hi, int? branch) + { + var q = _db.Set().AsNoTracking() + .Where(b => b.Status == BundleSaleStatus.Posted && b.CashAmount != 0 && b.BundleDate < hi); + if (lo is not null) q = q.Where(b => b.BundleDate >= lo); + return branch is null ? q : q.Where(b => b.Warehouse!.BranchId == branch); + } + + /// Cash received against Posted invoices — whatever day the invoice itself is from. + private IQueryable InvoicePayments(DateTime? lo, DateTime hi, int? branch) + { + var q = _db.Set().AsNoTracking() + .Where(p => p.PaymentMethod == SalesPaymentMethod.Cash && p.SalesInvoice!.Status == SalesInvoiceStatus.Posted && p.PaymentDate < hi); + if (lo is not null) q = q.Where(p => p.PaymentDate >= lo); + return branch is null ? q : q.Where(p => p.SalesInvoice!.BranchId == branch); + } + + private IQueryable ExpensePayments(DateTime? lo, DateTime hi, int? branch) + { + var q = _db.Set().AsNoTracking() + .Where(p => p.PaymentMethod == ExpensePaymentMethod.Cash && p.Expense!.Status != ExpenseStatus.Cancelled && p.PaymentDate < hi); + if (lo is not null) q = q.Where(p => p.PaymentDate >= lo); + return branch is null ? q : q.Where(p => p.Expense!.BranchId == branch); + } + + /// Posted deposits by business date; inclusive (null = no bound), exclusive. + private IQueryable Deposits(DateOnly? lo, DateOnly hi, int? branch) + { + var q = _db.Set().AsNoTracking() + .Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate < hi); + if (lo is not null) q = q.Where(d => d.BusinessDate >= lo); + return branch is null ? q : q.Where(d => d.BranchId == branch); + } +} diff --git a/Backend/ERPCore/Repositories/CategoryRepository.cs b/Backend/ERPCore/Repositories/CategoryRepository.cs new file mode 100644 index 0000000..622e65e --- /dev/null +++ b/Backend/ERPCore/Repositories/CategoryRepository.cs @@ -0,0 +1,79 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class CategoryRepository : Repository, ICategoryRepository +{ + public CategoryRepository(ErpDbContext db) : base(db) { } + + private DbSet SubCategories => Db.Set(); + + // --- Categories ------------------------------------------------------------------------------ + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default) + { + var q = Set.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(c => c.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(c => c.Name).Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public Task GetForReadAsync(int categoryId, CancellationToken ct = default) + => Set.AsNoTracking().FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct); + + public Task ExistsAsync(int categoryId, CancellationToken ct = default) + => Set.AnyAsync(c => c.CategoryId == categoryId, ct); + + public Task NameExistsAsync(string name, int? excludeCategoryId = null, CancellationToken ct = default) + { + var lowered = name.ToLower(); + return Set.AnyAsync(c => c.Name.ToLower() == lowered && (excludeCategoryId == null || c.CategoryId != excludeCategoryId), ct); + } + + // --- Subcategories --------------------------------------------------------------------------- + + public async Task<(IReadOnlyList Rows, int Total)> ListSubCategoriesPagedAsync( + int categoryId, string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default) + { + var q = SubCategories.AsNoTracking().Where(s => s.CategoryId == categoryId); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(s => EF.Functions.Like(s.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(s => s.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(s => s.Name).Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public Task GetSubCategoryForReadAsync(int subCategoryId, CancellationToken ct = default) + => SubCategories.AsNoTracking().FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct); + + public async Task GetSubCategoryForUpdateAsync(int subCategoryId, CancellationToken ct = default) + => await SubCategories.FindAsync(new object[] { subCategoryId }, ct); + + public Task SubCategoryNameExistsAsync(int categoryId, string name, int? excludeSubCategoryId = null, CancellationToken ct = default) + { + var lowered = name.ToLower(); + return SubCategories.AnyAsync(s => s.CategoryId == categoryId && s.Name.ToLower() == lowered + && (excludeSubCategoryId == null || s.SubCategoryId != excludeSubCategoryId), ct); + } + + public async Task AddSubCategoryAsync(SubCategory subCategory, CancellationToken ct = default) + => await SubCategories.AddAsync(subCategory, ct); +} diff --git a/Backend/ERPCore/Repositories/CountRepository.cs b/Backend/ERPCore/Repositories/CountRepository.cs new file mode 100644 index 0000000..56e0b4d --- /dev/null +++ b/Backend/ERPCore/Repositories/CountRepository.cs @@ -0,0 +1,84 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class CountRepository : Repository, ICountRepository +{ + public CountRepository(ErpDbContext db) : base(db) { } + + private IQueryable Layers(int itemId, int warehouseId) + => Db.Set().AsNoTracking().Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId); + + // --- Counts ---------------------------------------------------------------------------------- + + public async Task<(IReadOnlyList<(StockCount Count, int LineCount)> Rows, int Total)> ListPagedAsync( + string? search, CountStatus? status, int? warehouseId, int? branchId, int skip, int take, CancellationToken ct = default) + { + var q = Set.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(c => EF.Functions.Like(c.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(c => c.Status == status); + if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId); + // Warehouses are shared across branches — the count carries its own owning BranchId. + if (branchId is not null) q = q.Where(c => c.BranchId == branchId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(c => c.CountId).Skip(skip).Take(take) + .Select(c => new { Count = c, LineCount = c.Lines.Count }) + .ToListAsync(ct); + return (rows.Select(r => (r.Count, r.LineCount)).ToList(), total); + } + + public Task GetForReadAsync(int countId, CancellationToken ct = default) + => WithLines(Set.AsNoTracking()).FirstOrDefaultAsync(c => c.CountId == countId, ct); + + public Task GetForUpdateAsync(int countId, CancellationToken ct = default) + => WithLines(Set).FirstOrDefaultAsync(c => c.CountId == countId, ct); + + private static IQueryable WithLines(IQueryable q) + => q.Include(c => c.Lines).ThenInclude(l => l.Batch) + .Include(c => c.Lines).ThenInclude(l => l.Serial); + + // --- Lookups --------------------------------------------------------------------------------- + + public Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default) + => Db.Set().AnyAsync(w => w.WarehouseId == warehouseId, ct); + + public Task ItemExistsAsync(int itemId, CancellationToken ct = default) + => Db.Set().AnyAsync(i => i.ItemId == itemId, ct); + + public Task GetItemAsync(int itemId, CancellationToken ct = default) + => Db.Set().AsNoTracking().FirstAsync(i => i.ItemId == itemId, ct); + + public Task GetAdjustmentReasonAsync(string code, CancellationToken ct = default) + => Db.Set().AsNoTracking() + .FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == code, ct); + + // --- Stock snapshot -------------------------------------------------------------------------- + + public async Task> ListBatchesAsync(int itemId, CancellationToken ct = default) + => await Db.Set().AsNoTracking().Where(b => b.ItemId == itemId).ToListAsync(ct); + + public async Task SumBatchQtyAsync(int itemId, int warehouseId, int? batchId, CancellationToken ct = default) + => await Layers(itemId, warehouseId).Where(l => l.BatchId == batchId).SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + + public async Task SumUnserializedQtyAsync(int itemId, int warehouseId, CancellationToken ct = default) + => await Layers(itemId, warehouseId).Where(l => l.SerialId == null).SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + + public async Task> ListCountableSerialsAsync(int itemId, int warehouseId, CancellationToken ct = default) + => await Db.Set().AsNoTracking() + .Where(s => s.ItemId == itemId && s.WarehouseId == warehouseId + && (s.Status == SerialStatus.InStock || s.Status == SerialStatus.Damaged)) + .OrderBy(s => s.SerialNo).ToListAsync(ct); + + public Task HasOpenSerialLayerAsync(int itemId, int warehouseId, int serialId, CancellationToken ct = default) + => Layers(itemId, warehouseId).AnyAsync(l => l.SerialId == serialId && l.QtyRemaining > 0, ct); +} diff --git a/Backend/ERPCore/Repositories/CustomerRepository.cs b/Backend/ERPCore/Repositories/CustomerRepository.cs new file mode 100644 index 0000000..d281987 --- /dev/null +++ b/Backend/ERPCore/Repositories/CustomerRepository.cs @@ -0,0 +1,101 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class CustomerRepository : Repository, ICustomerRepository +{ + public CustomerRepository(ErpDbContext db) : base(db) { } + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + CustomerListCriteria criteria, int skip, int take, CancellationToken ct = default) + { + var q = Set.AsNoTracking(); + var cashCode = criteria.CashCustomerCode; + var cashCustomerId = await Db.Set().AsNoTracking() + .Where(c => c.ConfigId == SalesConfig.SingletonId) + .Select(c => c.CashCustomerId) + .FirstOrDefaultAsync(ct); + + // The generic cash customer is a system default for walk-in / one-time buyers, not a registered + // customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names). + if (!criteria.IncludeCashCustomer) + q = q.Where(c => c.CustomerCode != cashCode && (cashCustomerId == null || c.CustomerId != cashCustomerId)); + // Branch-wise, same as the Sales / GRN lists: a customer carries the BranchId it was created + // under. The cash customer is shared by every branch, so it is never filtered out here. + if (criteria.BranchId is { } branchId) + q = q.Where(c => c.BranchId == branchId + || c.CustomerCode == cashCode + || (cashCustomerId != null && c.CustomerId == cashCustomerId)); + if (!string.IsNullOrWhiteSpace(criteria.Search)) + { + var term = criteria.Search.Trim(); + q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%") + || EF.Functions.Like(c.CustomerCode, $"%{term}%") + || (c.DisplayName != null && EF.Functions.Like(c.DisplayName, $"%{term}%"))); + } + if (criteria.Status is not null) q = q.Where(c => c.Status == criteria.Status); + if (criteria.CustomerType is not null) q = q.Where(c => c.CustomerType == criteria.CustomerType); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(c => c.Name).Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public async Task> GetReceivablesAsync( + IReadOnlyCollection customerIds, CancellationToken ct = default) + { + if (customerIds.Count == 0) return new Dictionary(); + var ids = customerIds.ToList(); + + var dues = await Db.Set().AsNoTracking() + .Where(i => ids.Contains(i.CustomerId) && i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0) + .GroupBy(i => i.CustomerId) + .Select(g => new { CustomerId = g.Key, Amount = g.Sum(i => i.BalanceAmount), Count = g.Count() }) + .ToDictionaryAsync(x => x.CustomerId, ct); + + var lastPaid = await Db.Set().AsNoTracking() + .Where(p => ids.Contains(p.SalesInvoice!.CustomerId)) + .GroupBy(p => p.SalesInvoice!.CustomerId) + .Select(g => new { CustomerId = g.Key, Last = g.Max(p => p.PaymentDate) }) + .ToDictionaryAsync(x => x.CustomerId, x => x.Last, ct); + + return dues.Keys.Union(lastPaid.Keys).ToDictionary( + id => id, + id => new CustomerReceivable( + dues.TryGetValue(id, out var due) ? due.Amount : 0m, + due?.Count ?? 0, + lastPaid.TryGetValue(id, out var last) ? last : null)); + } + + public Task GetForReadAsync(int customerId, CancellationToken ct = default) + => Set.AsNoTracking().FirstOrDefaultAsync(c => c.CustomerId == customerId, ct); + + public Task CodeExistsAsync(string code, int? excludeCustomerId = null, CancellationToken ct = default) + { + var lowered = code.ToLower(); + return Set.AnyAsync(c => c.CustomerCode.ToLower() == lowered && (excludeCustomerId == null || c.CustomerId != excludeCustomerId), ct); + } + + public Task EmailExistsAsync(string email, int? excludeCustomerId = null, CancellationToken ct = default) + { + var lowered = email.ToLower(); + return Set.AnyAsync(c => c.Email != null && c.Email.ToLower() == lowered + && (excludeCustomerId == null || c.CustomerId != excludeCustomerId), ct); + } + + public Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default) + => Db.Set().AnyAsync(w => w.WarehouseId == warehouseId, ct); + + public Task GetFieldSwitchesAsync(CancellationToken ct = default) + => Db.Set().AsNoTracking() + .Where(c => c.ConfigId == SalesConfig.SingletonId) + .Select(c => new CustomerFieldSwitches( + c.CustomerPhoneEnabled, c.CustomerEmailEnabled, c.CustomerAddressEnabled, c.CustomerCityEnabled, + c.CustomerCountryEnabled, c.CustomerCreditLimitEnabled, c.CustomerCreditDaysEnabled)) + .FirstOrDefaultAsync(ct); +} diff --git a/Backend/ERPCore/Repositories/DayEndReportRepository.cs b/Backend/ERPCore/Repositories/DayEndReportRepository.cs new file mode 100644 index 0000000..e63cc29 --- /dev/null +++ b/Backend/ERPCore/Repositories/DayEndReportRepository.cs @@ -0,0 +1,97 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . Every query is read-only (no tracking). +public sealed class DayEndReportRepository : IDayEndReportRepository +{ + /// Ids per IN (...) when summing ledger cost, to keep the statement a sane size. + private const int IdChunk = 1000; + + private readonly ErpDbContext _db; + + public DayEndReportRepository(ErpDbContext db) => _db = db; + + public async Task> ListPostedSlipsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default) + { + var q = _db.Set().AsNoTracking() + .Where(s => s.Status == SalesSlipStatus.Posted && s.SlipDate >= start && s.SlipDate < end); + if (branchId is not null) q = q.Where(s => s.Warehouse!.BranchId == branchId); + return await q + .Select(s => new DayEndSlipRow(s.SalesSlipId, s.SlipNo, s.SlipDate, s.CustomerSnapshotName, s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount)) + .ToListAsync(ct); + } + + public async Task> ListPostedBundlesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default) + { + var q = _db.Set().AsNoTracking() + .Where(b => b.Status == BundleSaleStatus.Posted && b.BundleDate >= start && b.BundleDate < end); + if (branchId is not null) q = q.Where(b => b.Warehouse!.BranchId == branchId); + return await q + .Select(b => new DayEndBundleRow(b.BundleSaleId, b.BundleNo, b.BundleDate, b.CustomerSnapshotName, b.GrandTotal, b.CashAmount, b.CardAmount)) + .ToListAsync(ct); + } + + public async Task> ListPostedInvoicesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default) + { + var q = _db.Set().AsNoTracking() + .Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= start && i.InvoiceDate < end); + if (branchId is not null) q = q.Where(i => i.BranchId == branchId); + return await q + .Select(i => new DayEndInvoiceRow(i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, i.CustomerSnapshotName, i.NetPayable, i.PaidAmount)) + .ToListAsync(ct); + } + + public async Task> ListInvoicePaymentsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default) + { + var q = _db.Set().AsNoTracking() + .Where(p => p.SalesInvoice!.Status == SalesInvoiceStatus.Posted + && ((p.SalesInvoice.InvoiceDate >= start && p.SalesInvoice.InvoiceDate < end) + || (p.PaymentDate >= start && p.PaymentDate < end))); + if (branchId is not null) q = q.Where(p => p.SalesInvoice!.BranchId == branchId); + return await q + .Select(p => new DayEndPaymentRow(p.SalesInvoiceId, p.SalesInvoice!.InvoiceDate, p.PaymentDate, p.PaymentMethod, p.Amount)) + .ToListAsync(ct); + } + + public async Task> GetCostByDocAsync(string docType, IEnumerable docIds, CancellationToken ct = default) + { + var result = new Dictionary(); + foreach (var chunk in docIds.Distinct().Chunk(IdChunk)) + { + var rows = await _db.Set().AsNoTracking() + .Where(l => l.SourceDocType == docType && l.Direction == Direction.Out && chunk.Contains(l.SourceDocId)) + .GroupBy(l => l.SourceDocId) + .Select(g => new { DocId = g.Key, Cost = g.Sum(l => l.Value) }) + .ToListAsync(ct); + foreach (var r in rows) result[r.DocId] = r.Cost; + } + return result; + } + + public async Task> ListPostedExpensesAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default) + { + var q = _db.Set().AsNoTracking() + .Where(e => e.Status == ExpenseStatus.Posted && e.ExpenseDate >= from && e.ExpenseDate <= to); + if (branchId is not null) q = q.Where(e => e.BranchId == branchId); + return await q + .OrderBy(e => e.ExpenseId) + .Select(e => new DayEndExpenseRow(e.ExpenseId, e.DocNo, e.ExpenseDate, e.Category!.Name, e.Title!.Name, e.Payee, e.Amount, e.PaidAmount)) + .ToListAsync(ct); + } + + public async Task> ListPostedDepositsAsync(DateOnly businessDate, int? branchId, CancellationToken ct = default) + { + var q = _db.Set().AsNoTracking() + .Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate == businessDate); + if (branchId is not null) q = q.Where(d => d.BranchId == branchId); + return await q + .OrderBy(d => d.BankDepositId) + .Select(d => new DayEndDepositRow(d.BankDepositId, d.DocNo, d.ToBankAccountName, d.ToBankName, d.ToAccountNumber, d.DepositSlipNo, d.DepositDate, d.Amount)) + .ToListAsync(ct); + } +} diff --git a/Backend/ERPCore/Repositories/ExpensePaymentRepository.cs b/Backend/ERPCore/Repositories/ExpensePaymentRepository.cs new file mode 100644 index 0000000..76faf6c --- /dev/null +++ b/Backend/ERPCore/Repositories/ExpensePaymentRepository.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Entities; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class ExpensePaymentRepository : Repository, IExpensePaymentRepository +{ + public ExpensePaymentRepository(ErpDbContext db) : base(db) { } + + public Task ExpenseExistsAsync(int expenseId, CancellationToken ct = default) + => Db.Set().AnyAsync(x => x.ExpenseId == expenseId, ct); + + public async Task> ListForExpenseAsync(int expenseId, CancellationToken ct = default) + => await Set.AsNoTracking() + .Where(p => p.ExpenseId == expenseId) + .OrderByDescending(p => p.ExpensePaymentId) + .ToListAsync(ct); +} diff --git a/Backend/ERPCore/Repositories/ExpenseRepository.cs b/Backend/ERPCore/Repositories/ExpenseRepository.cs new file mode 100644 index 0000000..f9702aa --- /dev/null +++ b/Backend/ERPCore/Repositories/ExpenseRepository.cs @@ -0,0 +1,75 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Expenses; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class ExpenseRepository : Repository, IExpenseRepository +{ + public ExpenseRepository(ErpDbContext db) : base(db) { } + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, ExpenseListFilter filter, DateOnly today, int skip, int take, CancellationToken ct = default) + { + IQueryable q = Set.AsNoTracking().Include(x => x.Category).Include(x => x.Title); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%") + || (x.Payee != null && EF.Functions.Like(x.Payee, $"%{term}%")) + || (x.BillReference != null && EF.Functions.Like(x.BillReference, $"%{term}%"))); + } + if (filter.Status is not null) q = q.Where(x => x.Status == filter.Status); + if (filter.PaymentStatus is not null) q = q.Where(x => x.Status == ExpenseStatus.Posted && x.PaymentStatus == filter.PaymentStatus); + if (filter.CategoryId is not null) q = q.Where(x => x.ExpenseCategoryId == filter.CategoryId); + if (filter.TitleId is not null) q = q.Where(x => x.ExpenseTitleId == filter.TitleId); + if (filter.From is not null) q = q.Where(x => x.ExpenseDate >= filter.From); + if (filter.To is not null) q = q.Where(x => x.ExpenseDate <= filter.To); + if (filter.OverdueOnly) + q = q.Where(x => x.Status == ExpenseStatus.Posted && x.BalanceAmount > 0 && x.DueDate != null && x.DueDate < today); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.ExpenseDate).ThenByDescending(x => x.ExpenseId) + .Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public Task GetWithDetailsAsync(int expenseId, bool tracking, CancellationToken ct = default) + { + var q = Set + .Include(x => x.Category) + .Include(x => x.Title) + .Include(x => x.Vendor) + .Include(x => x.Payments); + return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct); + } + + public Task GetTitleWithCategoryAsync(int expenseTitleId, CancellationToken ct = default) + => Db.Set().Include(t => t.Category).FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct); + + public Task GetVendorNameAsync(int vendorId, CancellationToken ct = default) + => Db.Set().AsNoTracking().Where(v => v.VendorId == vendorId).Select(v => (string?)v.Name).FirstOrDefaultAsync(ct); + + // --- Summary report -------------------------------------------------------------------------- + + private IQueryable Posted => Set.AsNoTracking().Where(x => x.Status == ExpenseStatus.Posted); + + public async Task> ListPostedInRangeAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + => await Posted + .Where(x => x.ExpenseDate >= from && x.ExpenseDate <= to) + .Select(x => new ExpenseSummaryRow( + x.ExpenseCategoryId, x.Category!.Name, x.Category.SortOrder, + x.ExpenseTitleId, x.Title!.Name, x.Amount, x.PaidAmount, x.BalanceAmount)) + .ToListAsync(ct); + + public async Task> ListOutstandingAsync(CancellationToken ct = default) + => (await Posted.Where(x => x.BalanceAmount > 0).Select(x => new { x.BalanceAmount, x.DueDate }).ToListAsync(ct)) + .Select(x => (x.BalanceAmount, x.DueDate)).ToList(); + + public Task CountDraftsAsync(CancellationToken ct = default) + => Set.CountAsync(x => x.Status == ExpenseStatus.Draft, ct); +} diff --git a/Backend/ERPCore/Repositories/ExpenseSetupRepository.cs b/Backend/ERPCore/Repositories/ExpenseSetupRepository.cs new file mode 100644 index 0000000..e5f08ff --- /dev/null +++ b/Backend/ERPCore/Repositories/ExpenseSetupRepository.cs @@ -0,0 +1,48 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class ExpenseSetupRepository : Repository, IExpenseSetupRepository +{ + public ExpenseSetupRepository(ErpDbContext db) : base(db) { } + + private DbSet Titles => Db.Set(); + + // --- Categories ------------------------------------------------------------------------------ + + public async Task> ListWithTitlesAsync(bool includeInactive, CancellationToken ct = default) + => await Set.AsNoTracking() + .Include(c => c.Titles) + .Where(c => includeInactive || c.Status == EntityStatus.Active) + .OrderBy(c => c.SortOrder).ThenBy(c => c.Name) + .ToListAsync(ct); + + public Task GetWithTitlesForUpdateAsync(int expenseCategoryId, CancellationToken ct = default) + => Set.Include(c => c.Titles).FirstOrDefaultAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct); + + public Task ExistsAsync(int expenseCategoryId, CancellationToken ct = default) + => Set.AnyAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct); + + public Task CategoryNameExistsAsync(string name, int excludeCategoryId, CancellationToken ct = default) + => Set.AnyAsync(c => c.Name == name && c.ExpenseCategoryId != excludeCategoryId, ct); + + // --- Titles ---------------------------------------------------------------------------------- + + public Task GetTitleForUpdateAsync(int expenseTitleId, CancellationToken ct = default) + => Titles.FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct); + + public Task TitleNameExistsAsync(int expenseCategoryId, string name, int? excludeTitleId, CancellationToken ct = default) + => Titles.AnyAsync(t => t.ExpenseCategoryId == expenseCategoryId && t.Name == name + && (excludeTitleId == null || t.ExpenseTitleId != excludeTitleId), ct); + + public async Task MaxTitleSortOrderAsync(int expenseCategoryId, CancellationToken ct = default) + => await Titles.Where(t => t.ExpenseCategoryId == expenseCategoryId).MaxAsync(t => (int?)t.SortOrder, ct) ?? 0; + + public async Task AddTitleAsync(ExpenseTitle title, CancellationToken ct = default) + => await Titles.AddAsync(title, ct); +} diff --git a/Backend/ERPCore/Repositories/GrnRepository.cs b/Backend/ERPCore/Repositories/GrnRepository.cs new file mode 100644 index 0000000..9e96ade --- /dev/null +++ b/Backend/ERPCore/Repositories/GrnRepository.cs @@ -0,0 +1,171 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class GrnRepository : Repository, IGrnRepository +{ + public GrnRepository(ErpDbContext db) : base(db) { } + + private DbSet Pos => Db.Set(); + private DbSet Vendors => Db.Set(); + + // --- GRNs ------------------------------------------------------------------------------------ + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + GrnListCriteria criteria, int skip, int take, CancellationToken ct = default) + { + var q = Set.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(criteria.Search)) + { + var term = criteria.Search.Trim(); + // Doc no., the PO's doc no., or the vendor's name / code (the vendor is the table's first column). + var vendorIdsMatching = Vendors + .Where(v => EF.Functions.Like(v.Name, $"%{term}%") || EF.Functions.Like(v.Code, $"%{term}%")) + .Select(v => v.VendorId); + var poIdsMatching = Pos + .Where(p => EF.Functions.Like(p.DocNo, $"%{term}%")) + .Select(p => p.PoId); + q = q.Where(g => EF.Functions.Like(g.DocNo, $"%{term}%") + || vendorIdsMatching.Contains(g.VendorId) + || (g.PoId != null && poIdsMatching.Contains(g.PoId.Value))); + } + if (criteria.Status is not null) q = q.Where(g => g.Status == criteria.Status); + if (criteria.PoId is not null) q = q.Where(g => g.PoId == criteria.PoId); + if (criteria.VendorId is not null) q = q.Where(g => g.VendorId == criteria.VendorId); + if (criteria.WarehouseId is not null) q = q.Where(g => g.WarehouseId == criteria.WarehouseId); + // Warehouses are shared across branches — GRN carries its own BranchId, set at creation. + if (criteria.BranchId is not null) q = q.Where(g => g.BranchId == criteria.BranchId); + if (criteria.CreatedFrom is { } start) q = q.Where(g => g.CreatedAt >= start); + if (criteria.CreatedBefore is { } end) q = q.Where(g => g.CreatedAt < end); + + var total = await q.CountAsync(ct); + var rows = await ApplySort(q, criteria.Sort) + .Skip(skip).Take(take) + .Select(g => new GrnListRow( + g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status, + g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal), + g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null)) + .ToListAsync(ct); + return (rows, total); + } + + private IQueryable ApplySort(IQueryable q, string? sort) + { + var key = (sort ?? "-created").Trim(); + var desc = key.StartsWith('-'); + key = key.TrimStart('-', '+').ToLowerInvariant(); + + IOrderedQueryable ordered = key switch + { + "vendor" => desc + ? q.OrderByDescending(g => Vendors.Where(v => v.VendorId == g.VendorId).Select(v => v.Name).FirstOrDefault()) + : q.OrderBy(g => Vendors.Where(v => v.VendorId == g.VendorId).Select(v => v.Name).FirstOrDefault()), + "total" => desc ? q.OrderByDescending(g => g.Lines.Sum(l => l.LineTotal)) : q.OrderBy(g => g.Lines.Sum(l => l.LineTotal)), + "status" => desc ? q.OrderByDescending(g => g.Status) : q.OrderBy(g => g.Status), + "created" => desc ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt), + _ => q.OrderByDescending(g => g.CreatedAt) + }; + return ordered.ThenByDescending(g => g.GrnId); + } + + public Task GetWithDetailsAsync(int grnId, CancellationToken ct = default) + => Set.AsNoTracking() + .Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers) + .Include(g => g.Lines).ThenInclude(l => l.Serials) + .Include(g => g.Lines).ThenInclude(l => l.Batch) + .Include(g => g.PurchaseInvoice) + .FirstOrDefaultAsync(g => g.GrnId == grnId, ct); + + public Task GetForConfirmAsync(int grnId, CancellationToken ct = default) + => Set.Include(g => g.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(g => g.GrnId == grnId, ct); + + public Task GetWithLinesForUpdateAsync(int grnId, CancellationToken ct = default) + => Set.Include(g => g.Lines).FirstOrDefaultAsync(g => g.GrnId == grnId, ct); + + // --- Purchase orders ------------------------------------------------------------------------- + + public Task GetPoWithLinesAsync(int poId, CancellationToken ct = default) + => Pos.AsNoTracking().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct); + + public Task GetPoWithLinesForUpdateAsync(int poId, CancellationToken ct = default) + => Pos.Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct); + + public async Task GetPoLineForUpdateAsync(int poLineId, CancellationToken ct = default) + => await Db.Set().FindAsync(new object[] { poLineId }, ct); + + public Task GetPoStatusAsync(int poId, CancellationToken ct = default) + => Pos.AsNoTracking().Where(p => p.PoId == poId).Select(p => (PurchaseOrderStatus?)p.Status).FirstOrDefaultAsync(ct); + + public async Task> GetPoDocNosAsync(IReadOnlyCollection poIds, CancellationToken ct = default) + { + var ids = poIds.ToList(); + return await Pos.AsNoTracking().Where(p => ids.Contains(p.PoId)).ToDictionaryAsync(p => p.PoId, p => p.DocNo, ct); + } + + // --- Lookups --------------------------------------------------------------------------------- + + public Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default) + => Db.Set().AnyAsync(w => w.WarehouseId == warehouseId, ct); + + public Task VendorExistsAsync(int vendorId, CancellationToken ct = default) + => Vendors.AnyAsync(v => v.VendorId == vendorId, ct); + + public async Task> GetVendorsAsync(IReadOnlyCollection vendorIds, CancellationToken ct = default) + { + var ids = vendorIds.ToList(); + return await Vendors.AsNoTracking().Where(v => ids.Contains(v.VendorId)).ToDictionaryAsync(v => v.VendorId, ct); + } + + public Task GetItemAsync(int itemId, CancellationToken ct = default) + => Db.Set().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == itemId, ct); + + public Task BinIsInWarehouseAsync(int binId, int warehouseId, CancellationToken ct = default) + => Db.Set().AnyAsync(b => b.BinId == binId && b.WarehouseId == warehouseId, ct); + + public Task GetTaxPercentageAsync(int taxRateId, CancellationToken ct = default) + => Db.Set().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct); + + // --- Batches & serials ----------------------------------------------------------------------- + + public Task FindBatchAsync(int itemId, string batchNo, CancellationToken ct = default) + => Db.Set().FirstOrDefaultAsync(b => b.ItemId == itemId && b.BatchNo == batchNo, ct); + + public async Task AddBatchAsync(Batch batch, CancellationToken ct = default) + => await Db.Set().AddAsync(batch, ct); + + public async Task> ListTakenSerialsAsync( + int itemId, IReadOnlyCollection serialNos, int take, CancellationToken ct = default) + { + var numbers = serialNos.ToList(); + return await Db.Set().AsNoTracking() + .Where(s => s.ItemId == itemId && numbers.Contains(s.SerialNo)) + .Select(s => s.SerialNo).Take(take).ToListAsync(ct); + } + + public async Task AddSerialAsync(Serial serial, CancellationToken ct = default) + => await Db.Set().AddAsync(serial, ct); + + public async Task AddSerialMovementAsync(SerialMovement movement, CancellationToken ct = default) + => await Db.Set().AddAsync(movement, ct); + + // --- Confirm result (idempotent replay) ------------------------------------------------------ + + public async Task> ListLayersForLinesAsync(IReadOnlyCollection grnLineIds, CancellationToken ct = default) + { + var ids = grnLineIds.ToList(); + return await Db.Set().AsNoTracking() + .Where(l => l.GrnLineId != null && ids.Contains(l.GrnLineId.Value)).ToListAsync(ct); + } + + public async Task> ListInboundLedgerIdsAsync(int grnId, CancellationToken ct = default) + => await Db.Set().AsNoTracking() + .Where(l => l.SourceDocType == DocumentTypes.Grn && l.SourceDocId == grnId && l.Direction == Direction.In) + .Select(l => l.LedgerId).ToListAsync(ct); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IBrandRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IBrandRepository.cs new file mode 100644 index 0000000..ea496ac --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IBrandRepository.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Brand data access (FR-MD-09). Owns every query runs, so the +/// service holds only the business rules (uniqueness, concurrency) — 00-CORE §4. +/// +public interface IBrandRepository : IRepository +{ + /// One page of brands, name-ordered, optionally filtered by a name search and status. Read-only (no tracking). + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default); + + /// A brand for display, not tracked — use to change one. + Task GetForReadAsync(int brandId, CancellationToken ct = default); + + /// True when another brand already has this name (case-insensitive), ignoring . + Task NameExistsAsync(string name, int? excludeBrandId = null, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IBundleSaleRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IBundleSaleRepository.cs new file mode 100644 index 0000000..808c5d7 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IBundleSaleRepository.cs @@ -0,0 +1,50 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Bundle sale data access — bundle sales, their templates, and the few lookups a bundle sale needs. +/// Owns every query runs, so the service holds only the +/// business rules (pricing, day-end guard, draft-only edits) — 00-CORE §4. +/// +public interface IBundleSaleRepository : IRepository +{ + // --- Templates ------------------------------------------------------------------------------- + + /// One page of templates (with lines), newest first, optionally filtered by code / name / description. Read-only. + Task<(IReadOnlyList Rows, int Total)> ListTemplatesPagedAsync( + string? search, int skip, int take, CancellationToken ct = default); + + /// A template with its lines, not tracked; null when it doesn't exist. + Task GetTemplateForReadAsync(int bundleSaleTemplateId, CancellationToken ct = default); + + Task AddTemplateAsync(BundleSaleTemplate template, CancellationToken ct = default); + + // --- Bundle sales ---------------------------------------------------------------------------- + + /// One page of bundle sales (with lines), newest first, with the list filters applied. Read-only. + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, BundleSaleStatus? status, int? customerId, int? warehouseId, int skip, int take, CancellationToken ct = default); + + /// A bundle sale with its lines, not tracked; null when it doesn't exist. + Task GetForReadAsync(int bundleSaleId, CancellationToken ct = default); + + /// A bundle sale with its lines, tracked so changes are saved; null when it doesn't exist. + Task GetForUpdateAsync(int bundleSaleId, CancellationToken ct = default); + + // --- Lookups --------------------------------------------------------------------------------- + + /// True when the cashier has already posted day-end for . + Task IsDayClosedAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default); + + /// The customer's display name (falling back to the legal name), as snapshotted onto the sale. + Task GetCustomerSnapshotNameAsync(int customerId, CancellationToken ct = default); + + Task ItemExistsAsync(int itemId, CancellationToken ct = default); + + Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default); + + /// The item's name, used as the bundle line description. + Task GetItemNameAsync(int itemId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/ICashInHandRepository.cs b/Backend/ERPCore/Repositories/Interfaces/ICashInHandRepository.cs new file mode 100644 index 0000000..3250435 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/ICashInHandRepository.cs @@ -0,0 +1,32 @@ +namespace ERPCore.Repositories.Interfaces; + +/// +/// Read-only cash movement queries behind . Cash comes in from Posted +/// slips and bundle sales (their CashAmount) and cash payments on Posted invoices; it goes out as cash +/// payments on non-cancelled expenses and as Posted bank deposits. The service owns the running-balance rules +/// (tracking start, carry-forward, local-day bucketing) — 00-CORE §4. +/// +/// Instant ranges are UTC: lo inclusive (null = no lower bound), hi exclusive. Deposits are +/// by business date, same bounds. branchId null = every branch. +/// +/// +public interface ICashInHandRepository +{ + /// Total cash received (slips + bundle sales + cash invoice payments) in [lo, hi). + Task SumCashInAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default); + + /// Total cash paid out on expenses in [lo, hi). + Task SumCashOutAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default); + + /// Total Posted bank deposits with a business date in [lo, hi). + Task SumDepositsAsync(DateOnly? lo, DateOnly hi, int? branchId, CancellationToken ct = default); + + /// Every cash receipt in [lo, hi) as (when, amount), for bucketing by local day. + Task> ListCashInAsync(DateTime lo, DateTime hi, int? branchId, CancellationToken ct = default); + + /// Every cash expense payment in [lo, hi) as (when, amount). + Task> ListCashOutAsync(DateTime lo, DateTime hi, int? branchId, CancellationToken ct = default); + + /// Every Posted bank deposit with a business date in [lo, hi) as (business date, amount). + Task> ListDepositsAsync(DateOnly lo, DateOnly hi, int? branchId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/ICategoryRepository.cs b/Backend/ERPCore/Repositories/Interfaces/ICategoryRepository.cs new file mode 100644 index 0000000..f492e7f --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/ICategoryRepository.cs @@ -0,0 +1,42 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Category + subcategory data access (FR-MD-04). Owns every query runs, +/// so the service holds only the business rules (uniqueness, active parent, concurrency) — 00-CORE §4. +/// +public interface ICategoryRepository : IRepository +{ + // --- Categories ------------------------------------------------------------------------------ + + /// One page of categories, name-ordered, optionally filtered by a name search and status. Read-only. + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default); + + /// A category for display, not tracked — use to change one. + Task GetForReadAsync(int categoryId, CancellationToken ct = default); + + Task ExistsAsync(int categoryId, CancellationToken ct = default); + + /// True when another category already has this name (case-insensitive), ignoring . + Task NameExistsAsync(string name, int? excludeCategoryId = null, CancellationToken ct = default); + + // --- Subcategories --------------------------------------------------------------------------- + + /// One page of a category's subcategories, name-ordered, with the search / status filters. Read-only. + Task<(IReadOnlyList Rows, int Total)> ListSubCategoriesPagedAsync( + int categoryId, string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default); + + /// A subcategory for display, not tracked. + Task GetSubCategoryForReadAsync(int subCategoryId, CancellationToken ct = default); + + /// A subcategory tracked for changes. + Task GetSubCategoryForUpdateAsync(int subCategoryId, CancellationToken ct = default); + + /// True when the category already has a subcategory with this name (case-insensitive), ignoring . + Task SubCategoryNameExistsAsync(int categoryId, string name, int? excludeSubCategoryId = null, CancellationToken ct = default); + + Task AddSubCategoryAsync(SubCategory subCategory, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/ICountRepository.cs b/Backend/ERPCore/Repositories/Interfaces/ICountRepository.cs new file mode 100644 index 0000000..8ac46f4 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/ICountRepository.cs @@ -0,0 +1,53 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Stock-count data access (FR-STK-08). Owns every query runs — the counts +/// themselves plus the stock snapshot reads (batches, serials, FIFO layers) a new count takes — so the service +/// holds only the counting rules (one line per lot, serials count 0/1, variances become an adjustment) — 00-CORE §4. +/// +public interface ICountRepository : IRepository +{ + // --- Counts ---------------------------------------------------------------------------------- + + /// One page of counts (newest first) with each count's line total, after the list filters. Read-only. + Task<(IReadOnlyList<(StockCount Count, int LineCount)> Rows, int Total)> ListPagedAsync( + string? search, CountStatus? status, int? warehouseId, int? branchId, int skip, int take, CancellationToken ct = default); + + /// A count with its lines (and their batch / serial), not tracked. + Task GetForReadAsync(int countId, CancellationToken ct = default); + + /// A count with its lines (and their batch / serial), tracked so changes are saved. + Task GetForUpdateAsync(int countId, CancellationToken ct = default); + + // --- Lookups --------------------------------------------------------------------------------- + + Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default); + + Task ItemExistsAsync(int itemId, CancellationToken ct = default); + + /// An item, not tracked; throws when it doesn't exist (callers check first). + Task GetItemAsync(int itemId, CancellationToken ct = default); + + /// The Adjustment-context reason code with this code, or null when it isn't configured. + Task GetAdjustmentReasonAsync(string code, CancellationToken ct = default); + + // --- Stock snapshot -------------------------------------------------------------------------- + + /// Every batch of the item. + Task> ListBatchesAsync(int itemId, CancellationToken ct = default); + + /// Quantity left in the item's FIFO layers at the warehouse for one batch — or, with null, stock with no batch. + Task SumBatchQtyAsync(int itemId, int warehouseId, int? batchId, CancellationToken ct = default); + + /// Quantity left in the item's FIFO layers at the warehouse that carry no serial (pre-tracking stock). + Task SumUnserializedQtyAsync(int itemId, int warehouseId, CancellationToken ct = default); + + /// The item's serials that should be at the warehouse (InStock or Damaged), by serial number. + Task> ListCountableSerialsAsync(int itemId, int warehouseId, CancellationToken ct = default); + + /// True when the serial still has stock in an open FIFO layer at the warehouse. + Task HasOpenSerialLayerAsync(int itemId, int warehouseId, int serialId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/ICustomerRepository.cs b/Backend/ERPCore/Repositories/Interfaces/ICustomerRepository.cs new file mode 100644 index 0000000..bc01dba --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/ICustomerRepository.cs @@ -0,0 +1,47 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// Filters for . +/// False keeps the shared walk-in "Cash Customer" out of the list. +/// Only customers created under this branch (the cash customer is shared, so it is never filtered out by branch). +/// Code of the system cash-customer row, recognised alongside SalesConfig's CashCustomerId. +public sealed record CustomerListCriteria( + string? Search, EntityStatus? Status, CustomerType? CustomerType, + bool IncludeCashCustomer, int? BranchId, string CashCustomerCode); + +/// Which optional customer fields Sales Configuration has switched on. +public sealed record CustomerFieldSwitches(bool Phone, bool Email, bool Address, bool City, bool Country, bool CreditLimit, bool CreditDays); + +/// A customer's receivable position: unpaid balance and count of Posted invoices, and their latest payment. +public sealed record CustomerReceivable(decimal OutstandingAmount, int OutstandingInvoiceCount, DateTime? LastPaymentDate); + +/// +/// Customer data access. Owns every query runs — customers, their +/// receivables, and the Sales Configuration switches for optional fields — so the service holds only the +/// business rules (unique code / email, field gating, concurrency) — 00-CORE §4. +/// +public interface ICustomerRepository : IRepository +{ + /// One page of customers, name-ordered, after . Read-only. + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + CustomerListCriteria criteria, int skip, int take, CancellationToken ct = default); + + /// Receivable position for each of these customers; customers with nothing owed / paid are absent. + Task> GetReceivablesAsync(IReadOnlyCollection customerIds, CancellationToken ct = default); + + /// A customer for display, not tracked — use to change one. + Task GetForReadAsync(int customerId, CancellationToken ct = default); + + /// True when another customer already has this code (case-insensitive), ignoring . + Task CodeExistsAsync(string code, int? excludeCustomerId = null, CancellationToken ct = default); + + /// True when another customer already has this email (case-insensitive), ignoring . + Task EmailExistsAsync(string email, int? excludeCustomerId = null, CancellationToken ct = default); + + Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default); + + /// The optional-field switches from Sales Configuration, or null when its row is missing. + Task GetFieldSwitchesAsync(CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IDayEndReportRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IDayEndReportRepository.cs new file mode 100644 index 0000000..2536f45 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IDayEndReportRepository.cs @@ -0,0 +1,50 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// A Posted sales slip in the window (slim projection). +public sealed record DayEndSlipRow(int SalesSlipId, string SlipNo, DateTime SlipDate, string CustomerName, decimal GrandTotal, decimal PaidAmount, decimal CashAmount, decimal CardAmount); + +/// A Posted bundle sale in the window (slim projection). Bundle sales are always fully paid. +public sealed record DayEndBundleRow(int BundleSaleId, string BundleNo, DateTime BundleDate, string CustomerName, decimal GrandTotal, decimal CashAmount, decimal CardAmount); + +/// A Posted sales invoice in the window (slim projection). +public sealed record DayEndInvoiceRow(int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, string CustomerName, decimal NetPayable, decimal PaidAmount); + +/// A payment on a Posted invoice, with that invoice's date. +public sealed record DayEndPaymentRow(int SalesInvoiceId, DateTime InvoiceDate, DateTime PaymentDate, SalesPaymentMethod PaymentMethod, decimal Amount); + +/// A Posted expense, with its category and title names. +public sealed record DayEndExpenseRow(int ExpenseId, string DocNo, DateOnly ExpenseDate, string CategoryName, string TitleName, string? Payee, decimal Amount, decimal PaidAmount); + +/// A Posted bank deposit. +public sealed record DayEndDepositRow(int BankDepositId, string DocNo, string ToBankAccountName, string? ToBankName, string? ToAccountNumber, string? DepositSlipNo, DateOnly DepositDate, decimal Amount); + +/// +/// Read-only queries behind . Every method returns slim rows; the service +/// buckets them into local business days and works out the totals and profit (00-CORE §4). +/// Instant windows are UTC [start, end); date ranges are inclusive. branchId null = every branch. +/// +public interface IDayEndReportRepository +{ + Task> ListPostedSlipsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default); + + Task> ListPostedBundlesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default); + + Task> ListPostedInvoicesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default); + + /// + /// Every payment that matters to the window: those against invoices dated inside it (the method split of Paid), + /// and those received inside it against earlier invoices (collections / net cash). + /// + Task> ListInvoicePaymentsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default); + + /// Sum of the stock ledger's Out value per document of — the FIFO cost each sale consumed. + Task> GetCostByDocAsync(string docType, IEnumerable docIds, CancellationToken ct = default); + + /// Posted expenses dated –, by expense id. + Task> ListPostedExpensesAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default); + + /// Posted bank deposits for one business date, by deposit id. + Task> ListPostedDepositsAsync(DateOnly businessDate, int? branchId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IExpensePaymentRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IExpensePaymentRepository.cs new file mode 100644 index 0000000..1beaac5 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IExpensePaymentRepository.cs @@ -0,0 +1,16 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Expense payment data access (docs/EXPENSE_MODULE_PLAN.md §4). Owns the queries +/// runs itself; loading the expense being paid stays with +/// , which the payment service shares. +/// +public interface IExpensePaymentRepository : IRepository +{ + Task ExpenseExistsAsync(int expenseId, CancellationToken ct = default); + + /// The expense's payment rows, newest first. Read-only. + Task> ListForExpenseAsync(int expenseId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IExpenseRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IExpenseRepository.cs new file mode 100644 index 0000000..e44613f --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IExpenseRepository.cs @@ -0,0 +1,46 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Expenses; + +namespace ERPCore.Repositories.Interfaces; + +/// A Posted expense in the summary window, with its category and title names. +public sealed record ExpenseSummaryRow( + int ExpenseCategoryId, string CategoryName, int CategorySort, + int ExpenseTitleId, string TitleName, decimal Amount, decimal PaidAmount, decimal BalanceAmount); + +/// +/// Expense data access (docs/EXPENSE_MODULE_PLAN.md §4). Owns every query +/// runs, so the service holds only the business rules (draft-only edits, title/category checks, GL accrual and +/// reversal) — 00-CORE §4. +/// +public interface IExpenseRepository : IRepository +{ + /// + /// One page of expenses (with category and title), newest expense date first, after the search and + /// . "Overdue" means Posted, with a balance, due before . Read-only. + /// + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, ExpenseListFilter filter, DateOnly today, int skip, int take, CancellationToken ct = default); + + /// An expense with its category, title, vendor and payments; tracked or read-only. + Task GetWithDetailsAsync(int expenseId, bool tracking, CancellationToken ct = default); + + /// + /// An expense title with its category, tracked on purpose: an update assigns it to the tracked expense's + /// navigations, and identity resolution hands back the instance the expense already loaded. + /// + Task GetTitleWithCategoryAsync(int expenseTitleId, CancellationToken ct = default); + + /// The vendor's name, or null when it doesn't exist. + Task GetVendorNameAsync(int vendorId, CancellationToken ct = default); + + // --- Summary report -------------------------------------------------------------------------- + + /// Posted expenses dated – (inclusive). + Task> ListPostedInRangeAsync(DateOnly from, DateOnly to, CancellationToken ct = default); + + /// Every Posted expense with a balance still outstanding, as (balance, due date). + Task> ListOutstandingAsync(CancellationToken ct = default); + + Task CountDraftsAsync(CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IExpenseSetupRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IExpenseSetupRepository.cs new file mode 100644 index 0000000..44d1759 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IExpenseSetupRepository.cs @@ -0,0 +1,37 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Expense category + title data access. Owns every query runs, +/// so the service holds only the setup rules (system rows keep their names, unique names, next sort order) — +/// 00-CORE §4. +/// +public interface IExpenseSetupRepository : IRepository +{ + // --- Categories ------------------------------------------------------------------------------ + + /// Categories with their titles, by sort order then name; only Active categories unless . Read-only. + Task> ListWithTitlesAsync(bool includeInactive, CancellationToken ct = default); + + /// A category with its titles, tracked so changes are saved. + Task GetWithTitlesForUpdateAsync(int expenseCategoryId, CancellationToken ct = default); + + Task ExistsAsync(int expenseCategoryId, CancellationToken ct = default); + + /// True when another category already has exactly this name. + Task CategoryNameExistsAsync(string name, int excludeCategoryId, CancellationToken ct = default); + + // --- Titles ---------------------------------------------------------------------------------- + + /// A title, tracked so changes are saved. + Task GetTitleForUpdateAsync(int expenseTitleId, CancellationToken ct = default); + + /// True when the category already has a title with exactly this name, ignoring . + Task TitleNameExistsAsync(int expenseCategoryId, string name, int? excludeTitleId, CancellationToken ct = default); + + /// The highest title sort order in the category, or 0 when it has none. + Task MaxTitleSortOrderAsync(int expenseCategoryId, CancellationToken ct = default); + + Task AddTitleAsync(ExpenseTitle title, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IGrnRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IGrnRepository.cs new file mode 100644 index 0000000..9b249b9 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IGrnRepository.cs @@ -0,0 +1,97 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// Filters for . Created bounds are UTC: CreatedFrom inclusive, CreatedBefore exclusive. +public sealed record GrnListCriteria( + string? Search, GrnStatus? Status, int? PoId, int? VendorId, int? WarehouseId, int? BranchId, + DateTime? CreatedFrom, DateTime? CreatedBefore, string? Sort); + +/// One GRN list row (slim projection) with its line count, total and linked purchase invoice. +public sealed record GrnListRow( + int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status, + int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue, + int? PurchaseInvoiceId, string? PurchaseInvoiceNo); + +/// +/// Goods-receipt data access. Owns every query runs — GRNs, the PO they +/// receive against, and the item / batch / serial / stock reads a receipt needs — so the service holds only the +/// receiving rules (tolerance, costing, tracking, GL posting) — 00-CORE §4. +/// +public interface IGrnRepository : IRepository +{ + // --- GRNs ------------------------------------------------------------------------------------ + + /// + /// One page of GRNs after , sorted server-side so paging stays correct: + /// created / vendor / total / status, a leading - for descending; default + /// (and any unknown key) is newest first, GrnId the tiebreaker. The search matches the doc no., the PO's doc no., + /// or the vendor's name / code. Read-only. + /// + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync(GrnListCriteria criteria, int skip, int take, CancellationToken ct = default); + + /// A GRN with its lines (warranty numbers, serials, batch) and purchase invoice, not tracked. + Task GetWithDetailsAsync(int grnId, CancellationToken ct = default); + + /// A GRN with its lines and their captured serials, tracked — for confirming. + Task GetForConfirmAsync(int grnId, CancellationToken ct = default); + + /// A GRN with its lines, tracked — for releasing holds. + Task GetWithLinesForUpdateAsync(int grnId, CancellationToken ct = default); + + // --- Purchase orders ------------------------------------------------------------------------- + + /// A PO with its lines, not tracked; null when it doesn't exist. + Task GetPoWithLinesAsync(int poId, CancellationToken ct = default); + + /// A PO with its lines, tracked so its status can be updated. + Task GetPoWithLinesForUpdateAsync(int poId, CancellationToken ct = default); + + /// A PO line, tracked so its received quantity can be updated. + Task GetPoLineForUpdateAsync(int poLineId, CancellationToken ct = default); + + Task GetPoStatusAsync(int poId, CancellationToken ct = default); + + /// Doc no. of each of these POs. + Task> GetPoDocNosAsync(IReadOnlyCollection poIds, CancellationToken ct = default); + + // --- Lookups --------------------------------------------------------------------------------- + + Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default); + + Task VendorExistsAsync(int vendorId, CancellationToken ct = default); + + /// Each of these vendors, by id. + Task> GetVendorsAsync(IReadOnlyCollection vendorIds, CancellationToken ct = default); + + /// An item, not tracked; null when it doesn't exist. + Task GetItemAsync(int itemId, CancellationToken ct = default); + + Task BinIsInWarehouseAsync(int binId, int warehouseId, CancellationToken ct = default); + + /// The tax rate's percentage; throws when it doesn't exist (an item only points at real rates). + Task GetTaxPercentageAsync(int taxRateId, CancellationToken ct = default); + + // --- Batches & serials ----------------------------------------------------------------------- + + /// The item's batch with this number, tracked; null when there is none yet. + Task FindBatchAsync(int itemId, string batchNo, CancellationToken ct = default); + + Task AddBatchAsync(Batch batch, CancellationToken ct = default); + + /// Up to of these serial numbers that already exist for the item. + Task> ListTakenSerialsAsync(int itemId, IReadOnlyCollection serialNos, int take, CancellationToken ct = default); + + Task AddSerialAsync(Serial serial, CancellationToken ct = default); + + Task AddSerialMovementAsync(SerialMovement movement, CancellationToken ct = default); + + // --- Confirm result (idempotent replay) ------------------------------------------------------ + + /// The FIFO layers created for these GRN lines. + Task> ListLayersForLinesAsync(IReadOnlyCollection grnLineIds, CancellationToken ct = default); + + /// Ids of the GRN's inbound stock-ledger rows. + Task> ListInboundLedgerIdsAsync(int grnId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IItemImageRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IItemImageRepository.cs new file mode 100644 index 0000000..81ab43b --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IItemImageRepository.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Item photo data access. Owns every query runs, so the service holds only +/// the upload rules (allowed types, size and count caps, config gate) and the file storage calls — 00-CORE §4. +/// +public interface IItemImageRepository : IRepository +{ + Task ItemExistsAsync(int itemId, CancellationToken ct = default); + + /// The item's images in display order (sort order, then id). Read-only. + Task> ListForItemAsync(int itemId, CancellationToken ct = default); + + /// The sort order of each of the item's images (one per image), for the count cap and the next slot. + Task> ListSortOrdersAsync(int itemId, CancellationToken ct = default); + + /// The image, only when it belongs to this item; tracked when it is to be changed or removed. + Task GetForItemAsync(int itemId, int itemImageId, bool tracking, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IItemRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IItemRepository.cs new file mode 100644 index 0000000..1f6e0d2 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IItemRepository.cs @@ -0,0 +1,102 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Items; + +namespace ERPCore.Repositories.Interfaces; + +/// Filters shared by the item list and the item overview. Search matches SKU or name. +public sealed record ItemListCriteria( + string? Search, EntityStatus? Status, int? CategoryId, int? SubCategoryId, int? BrandId, + TrackingMode? TrackingMode, StockNature? StockNature = null, ItemStockFilter? Stock = null); + +/// A confirmed GRN line for the item: when it was posted and from which vendor. +public sealed record ItemGrnPurchaseRow(int ItemId, DateTime PostedAt, int VendorId, string VendorName); + +/// One confirmed purchase of the item, newest first, for the purchase-price history. +public sealed record ItemPurchasePriceRow( + int GrnId, string GrnDocNo, DateTime PostedAt, int VendorId, string? VendorName, string? WarehouseCode, + decimal Qty, decimal UnitCost, decimal DiscountPct, decimal NetUnitCost, decimal VatPct); + +/// +/// Item master data access. Owns every query runs — items, their stock and +/// purchase / sales figures, the masters an item points at, and code scanning — so the service holds only the +/// item rules (uniqueness, config gating, warranty, frozen base UOM, price approval) — 00-CORE §4. +/// +public interface IItemRepository : IRepository +{ + // --- Lists ----------------------------------------------------------------------------------- + + /// One page of items (with tax rate) by SKU, after . Read-only. + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync(ItemListCriteria criteria, int skip, int take, CancellationToken ct = default); + + /// + /// One page of items (with reorder settings) by SKU, after — including the stock-state + /// filter, applied as a subquery before paging so totals and pages stay correct. Read-only. + /// + Task<(IReadOnlyList Rows, int Total)> ListOverviewPagedAsync(ItemListCriteria criteria, int skip, int take, CancellationToken ct = default); + + /// Current FIFO stock (qty, value) per item over every open layer and warehouse (negative = sold beyond stock). + Task> GetStockByItemAsync(IReadOnlyCollection itemIds, CancellationToken ct = default); + + /// On-hand per (item, warehouse). + Task> GetOnHandByItemWarehouseAsync(IReadOnlyCollection itemIds, CancellationToken ct = default); + + /// Every confirmed GRN line of these items, with its post time and vendor. + Task> ListConfirmedPurchasesAsync(IReadOnlyCollection itemIds, CancellationToken ct = default); + + /// Qty that left stock on these document types since , per item (stock ledger Out rows). + Task> GetQtyOutSinceAsync( + IReadOnlyCollection itemIds, DateTime since, IReadOnlyCollection docTypes, CancellationToken ct = default); + + /// The item's latest confirmed purchases, newest first. + Task> ListPurchasePricesAsync(int itemId, int take, CancellationToken ct = default); + + // --- Single item ----------------------------------------------------------------------------- + + Task ExistsAsync(int itemId, CancellationToken ct = default); + + /// An item with its reorder settings and tax rate; tracked when it is to be changed. + Task GetWithDetailsAsync(int itemId, bool tracking, CancellationToken ct = default); + + /// True when another item already has this SKU, ignoring . + Task SkuExistsAsync(string sku, int? excludeItemId = null, CancellationToken ct = default); + + /// True when another item already has this barcode, ignoring . + Task BarcodeExistsAsync(string barcode, int? excludeItemId = null, CancellationToken ct = default); + + /// + /// Whether anything has ever been recorded against this item's stock — live layers or the ledger, because a + /// fully consumed item has no layer left but its history still reads in the old unit. + /// + Task HasStockHistoryAsync(int itemId, CancellationToken ct = default); + + /// Settings → Approvals price-adjustment levels, or null when that row is missing. + Task GetPriceAdjustmentLevelsAsync(CancellationToken ct = default); + + // --- Referenced masters ---------------------------------------------------------------------- + + Task CategoryExistsAsync(int categoryId, CancellationToken ct = default); + + Task GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default); + + Task GetBrandAsync(int brandId, CancellationToken ct = default); + + Task UomExistsAsync(int uomId, CancellationToken ct = default); + + Task GetVendorAsync(int vendorId, CancellationToken ct = default); + + Task GetTaxRateAsync(int taxRateId, CancellationToken ct = default); + + Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default); + + // --- Scanning (each result carries its Item) ------------------------------------------------- + + /// An item whose barcode or SKU is . + Task FindItemByCodeAsync(string code, CancellationToken ct = default); + + /// A batch whose barcode or batch no. is , limited to one item when is given. + Task FindBatchByCodeAsync(string code, int? itemId = null, CancellationToken ct = default); + + /// A serial whose barcode or serial no. is , limited to one item when is given. + Task FindSerialByCodeAsync(string code, int? itemId = null, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IItemTypeRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IItemTypeRepository.cs new file mode 100644 index 0000000..17ae417 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IItemTypeRepository.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Item type data access (FR-MD-10). Owns every query runs, so the service +/// holds only the business rules (name uniqueness, concurrency) — 00-CORE §4. +/// +public interface IItemTypeRepository : IRepository +{ + /// One page of item types, name-ordered, optionally filtered by a name search and status. Read-only. + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default); + + /// An item type for display, not tracked — use to change one. + Task GetForReadAsync(int itemTypeId, CancellationToken ct = default); + + /// True when another item type already has this name (case-insensitive), ignoring . + Task NameExistsAsync(string name, int? excludeItemTypeId = null, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/INumberSequenceRepository.cs b/Backend/ERPCore/Repositories/Interfaces/INumberSequenceRepository.cs new file mode 100644 index 0000000..2620365 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/INumberSequenceRepository.cs @@ -0,0 +1,18 @@ +namespace ERPCore.Repositories.Interfaces; + +/// +/// Document-number data access behind : the branch code that goes into +/// a number, and the atomic counter bump itself. The service owns the number format — 00-CORE §4. +/// +public interface INumberSequenceRepository +{ + /// The branch's code, or null when the branch doesn't exist. + Task GetBranchCodeAsync(int branchId, CancellationToken ct = default); + + /// + /// Atomically issues the next number for (, , + /// ) — 1 for a new key — enlisted in the DbContext's current transaction when there is one, + /// so it commits or rolls back with the document. Safe to call outside a transaction as well. + /// + Task IncrementAsync(string docType, int branchId, int year, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IOpeningStockRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IOpeningStockRepository.cs new file mode 100644 index 0000000..9fd96f1 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IOpeningStockRepository.cs @@ -0,0 +1,30 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Opening-stock data access. Owns every query runs and the rows it +/// writes directly (stock ledger, journal stubs) — the service keeps the opening rules and the GL posting — 00-CORE §4. +/// +public interface IOpeningStockRepository : IRepository +{ + /// Every opening-stock run (newest first) with its line count. Read-only. + Task> ListStockRunsAsync(CancellationToken ct = default); + + /// An opening-stock run with its lines (and their item / warehouse), not tracked; null when not found or not a stock run. + Task GetStockRunAsync(int runId, CancellationToken ct = default); + + /// Each of these items, by id. Read-only. + Task> GetItemsAsync(IReadOnlyCollection itemIds, CancellationToken ct = default); + + /// Each of these warehouses, by id. Read-only. + Task> GetWarehousesAsync(IReadOnlyCollection warehouseIds, CancellationToken ct = default); + + /// The distinct (item, warehouse) pairs among these that already have any stock-ledger movement. + Task> ListMovedPairsAsync( + IReadOnlyCollection itemIds, IReadOnlyCollection warehouseIds, CancellationToken ct = default); + + Task AddLedgerAsync(StockLedger row, CancellationToken ct = default); + + Task AddJournalStubAsync(JournalEntryStub stub, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IPriceAdjustmentRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IPriceAdjustmentRepository.cs new file mode 100644 index 0000000..a1e1804 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IPriceAdjustmentRepository.cs @@ -0,0 +1,47 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// One price-adjustment list row (slim projection) with its line count and creator's name. +public sealed record PriceAdjustmentListRow( + int PriceAdjustmentId, string DocNo, string Reason, PriceAdjustmentStatus Status, int RequiredApprovals, + int LineCount, int CreatedBy, string? CreatorName, DateTime CreatedAt, DateTime? AppliedAt); + +/// +/// Price-adjustment data access (docs/DUAL_APPROVAL_PLAN.md §6). Owns every query +/// runs — requests, the items they reprice, price history and the +/// names shown beside it — so the service holds only the approval and repricing rules — 00-CORE §4. +/// +public interface IPriceAdjustmentRepository : IRepository +{ + /// One page of requests, newest first, optionally filtered by doc no. / reason, status and an item on them. Read-only. + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, PriceAdjustmentStatus? status, int? itemId, int skip, int take, CancellationToken ct = default); + + /// A request with its lines (and their items) and creator, not tracked. + Task GetWithDetailsAsync(int priceAdjustmentId, CancellationToken ct = default); + + /// A request with its lines, tracked — for deciding on it. + Task GetForDecisionAsync(int priceAdjustmentId, CancellationToken ct = default); + + /// Each of these items, by id, tracked so their prices can be changed. + Task> GetItemsForUpdateAsync(IReadOnlyCollection itemIds, CancellationToken ct = default); + + /// For each of these items that sits on a pending (level 1 or 2) request: the item and that request's doc no. + Task> ListPendingRequestsForItemsAsync(IReadOnlyCollection itemIds, CancellationToken ct = default); + + Task AddHistoryAsync(ItemPriceHistory history, CancellationToken ct = default); + + /// The item's applied price changes, newest first. Read-only. + Task> ListItemHistoryAsync(int itemId, CancellationToken ct = default); + + /// Display name of each of these users. + Task> GetUserNamesAsync(IReadOnlyCollection userIds, CancellationToken ct = default); + + /// Doc no. of each of these requests. + Task> GetDocNosAsync(IReadOnlyCollection priceAdjustmentIds, CancellationToken ct = default); + + /// Settings → Approvals price-adjustment levels, or null when that row is missing. + Task GetPriceAdjustmentLevelsAsync(CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IProcurementConfigRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IProcurementConfigRepository.cs new file mode 100644 index 0000000..ed760bf --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IProcurementConfigRepository.cs @@ -0,0 +1,13 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Procurement configuration data access — the singleton row seeded by DataSeeder. Owns the queries +/// runs (00-CORE §4). +/// +public interface IProcurementConfigRepository : IRepository +{ + /// The singleton row, not tracked; null when it hasn't been seeded. Use to change it. + Task GetForReadAsync(CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IProductConfigRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IProductConfigRepository.cs new file mode 100644 index 0000000..16220d8 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IProductConfigRepository.cs @@ -0,0 +1,13 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Product configuration data access (FR-MD-11) — the singleton row seeded by DataSeeder. Owns the queries +/// runs (00-CORE §4). +/// +public interface IProductConfigRepository : IRepository +{ + /// The singleton row, not tracked; null when it hasn't been seeded. Use to change it. + Task GetForReadAsync(CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IPurchaseInvoicePaymentRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IPurchaseInvoicePaymentRepository.cs new file mode 100644 index 0000000..f859e34 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IPurchaseInvoicePaymentRepository.cs @@ -0,0 +1,18 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Vendor payment data access. Owns every query runs, so the +/// service holds only the payment rules and the GL posting — 00-CORE §4. +/// +public interface IPurchaseInvoicePaymentRepository : IRepository +{ + Task InvoiceExistsAsync(int purchaseInvoiceId, CancellationToken ct = default); + + /// The purchase invoice, tracked so its paid / balance amounts can be updated. + Task GetInvoiceForUpdateAsync(int purchaseInvoiceId, CancellationToken ct = default); + + /// The invoice's payments, newest first. Read-only. + Task> ListForInvoiceAsync(int purchaseInvoiceId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IPurchaseInvoiceRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IPurchaseInvoiceRepository.cs new file mode 100644 index 0000000..65abbb2 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IPurchaseInvoiceRepository.cs @@ -0,0 +1,36 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// A confirmed, not-yet-invoiced GRN of a vendor, with its line totals. +public sealed record EligibleGrnRow( + int GrnId, string DocNo, DateTime ReceivedAt, int WarehouseId, int LineCount, + decimal Subtotal, decimal VatTotal, decimal GrandTotal); + +/// +/// Purchase invoice data access. Owns every query runs — invoices, +/// the GRNs they consolidate, and the vendor — so the service holds only the invoicing rules and the GL posting +/// (00-CORE §4). +/// +public interface IPurchaseInvoiceRepository : IRepository +{ + /// One page of invoices (with lines), newest first, optionally filtered by doc no. / vendor invoice no. / vendor name, status and vendor. Read-only. + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, PurchaseInvoiceStatus? status, int? vendorId, int skip, int take, CancellationToken ct = default); + + /// An invoice with its lines (and their GRNs); tracked when it is to be changed. + Task GetWithLinesAsync(int purchaseInvoiceId, bool tracking, CancellationToken ct = default); + + /// The vendor, not tracked; null when it doesn't exist. + Task GetVendorAsync(int vendorId, CancellationToken ct = default); + + /// True when a non-cancelled invoice of this vendor already carries this vendor invoice no., ignoring . + Task VendorInvoiceNoExistsAsync(int vendorId, string vendorInvoiceNo, int? excludePurchaseInvoiceId, CancellationToken ct = default); + + /// These GRNs with their lines, tracked so they can be linked to / freed from an invoice. + Task> ListGrnsForInvoicingAsync(IReadOnlyCollection grnIds, CancellationToken ct = default); + + /// The vendor's confirmed GRNs not yet on an invoice, oldest receipt first. + Task> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IPurchaseOrderRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IPurchaseOrderRepository.cs new file mode 100644 index 0000000..ce6d5e7 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IPurchaseOrderRepository.cs @@ -0,0 +1,43 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Repositories.Interfaces; + +/// Filters for . Created bounds are UTC: CreatedFrom inclusive, CreatedBefore exclusive. +public sealed record PurchaseOrderListCriteria( + string? Search, PurchaseOrderStatus? Status, int? VendorId, DateTime? CreatedFrom, DateTime? CreatedBefore, string? Sort); + +/// +/// Purchase-order data access. Owns every query runs — POs, their +/// vendor, and the references a PO line points at — so the service holds only the PO rules (approval flow, +/// editability, one branch per PO, tax gating) — 00-CORE §4. +/// +public interface IPurchaseOrderRepository : IRepository +{ + /// + /// One page of POs (with lines) after , sorted server-side so paging stays correct: + /// created / vendor / total / status, a leading - for descending; default (and any + /// unknown key) is newest first, PoId the tiebreaker. The search matches the doc no. or the vendor's name / code. Read-only. + /// + Task<(IReadOnlyList Rows, int Total)> ListPagedAsync(PurchaseOrderListCriteria criteria, int skip, int take, CancellationToken ct = default); + + /// A PO with its lines; tracked when it is to be changed. + Task GetWithLinesAsync(int poId, bool tracking, CancellationToken ct = default); + + /// The vendor, not tracked; null when it doesn't exist. + Task GetVendorAsync(int vendorId, CancellationToken ct = default); + + /// Each of these vendors, by id. Read-only. + Task> GetVendorsAsync(IReadOnlyCollection vendorIds, CancellationToken ct = default); + + Task RequisitionExistsAsync(int requisitionId, CancellationToken ct = default); + + /// Which of these item ids exist. + Task> ListExistingItemIdsAsync(IReadOnlyCollection itemIds, CancellationToken ct = default); + + /// Which of these warehouse ids exist. + Task> ListExistingWarehouseIdsAsync(IReadOnlyCollection warehouseIds, CancellationToken ct = default); + + /// Which of these warehouses belong to a branch other than . + Task> ListWarehousesOutsideBranchAsync(IReadOnlyCollection warehouseIds, int branchId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/Interfaces/IPurchaseReturnRepository.cs b/Backend/ERPCore/Repositories/Interfaces/IPurchaseReturnRepository.cs new file mode 100644 index 0000000..8699409 --- /dev/null +++ b/Backend/ERPCore/Repositories/Interfaces/IPurchaseReturnRepository.cs @@ -0,0 +1,45 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Repositories.Interfaces; + +/// +/// Purchase-return data access (FR-PROC-08). Owns every query runs — +/// returns, their ledger rows, the references a return names, and the rejected GRN-line stock it can release — +/// so the service holds only the return rules, the stock movement and the GL posting (00-CORE §4). +/// +public interface IPurchaseReturnRepository : IRepository +{ + /// One page of returns (newest first) with each one's line count, optionally filtered by doc no., vendor and warehouse. Read-only. + Task<(IReadOnlyList<(PurchaseReturn Return, int LineCount)> Rows, int Total)> ListPagedAsync( + string? search, int? vendorId, int? warehouseId, int skip, int take, CancellationToken ct = default); + + /// A return with its lines, not tracked. + Task GetWithLinesAsync(int returnId, CancellationToken ct = default); + + /// The return's stock-ledger row ids, in posting order (polymorphic source-doc reference, docs/10 C.9). + Task> ListLedgerIdsAsync(int returnId, CancellationToken ct = default); + + // --- References ------------------------------------------------------------------------------ + + Task VendorExistsAsync(int vendorId, CancellationToken ct = default); + + Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default); + + Task GetReasonCodeAsync(int reasonCodeId, CancellationToken ct = default); + + Task GetItemAsync(int itemId, CancellationToken ct = default); + + /// A GRN line, not tracked; null when it doesn't exist. + Task GetGrnLineAsync(int grnLineId, CancellationToken ct = default); + + // --- Rejected GRN-line stock ----------------------------------------------------------------- + + /// Quantity still sitting in the GRN line's own FIFO layer(s). + Task SumRemainingOnGrnLineAsync(int grnLineId, CancellationToken ct = default); + + /// The GRN line's layers with stock left, oldest first, tracked so they can be consumed. + Task> ListOpenLayersForGrnLineAsync(int grnLineId, CancellationToken ct = default); + + /// These serials, tracked so their status can be moved. + Task> ListSerialsAsync(IReadOnlyCollection serialIds, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Repositories/ItemImageRepository.cs b/Backend/ERPCore/Repositories/ItemImageRepository.cs new file mode 100644 index 0000000..fec9d7a --- /dev/null +++ b/Backend/ERPCore/Repositories/ItemImageRepository.cs @@ -0,0 +1,30 @@ +using ERPCore.Domain.Entities; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class ItemImageRepository : Repository, IItemImageRepository +{ + public ItemImageRepository(ErpDbContext db) : base(db) { } + + public Task ItemExistsAsync(int itemId, CancellationToken ct = default) + => Db.Set().AnyAsync(i => i.ItemId == itemId, ct); + + public async Task> ListForItemAsync(int itemId, CancellationToken ct = default) + => await Set.AsNoTracking() + .Where(i => i.ItemId == itemId) + .OrderBy(i => i.SortOrder).ThenBy(i => i.ItemImageId) + .ToListAsync(ct); + + public async Task> ListSortOrdersAsync(int itemId, CancellationToken ct = default) + => await Set.Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct); + + public Task GetForItemAsync(int itemId, int itemImageId, bool tracking, CancellationToken ct = default) + { + var q = tracking ? Set : Set.AsNoTracking(); + return q.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct); + } +} diff --git a/Backend/ERPCore/Repositories/ItemRepository.cs b/Backend/ERPCore/Repositories/ItemRepository.cs new file mode 100644 index 0000000..fdaf8cb --- /dev/null +++ b/Backend/ERPCore/Repositories/ItemRepository.cs @@ -0,0 +1,189 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Items; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class ItemRepository : Repository, IItemRepository +{ + public ItemRepository(ErpDbContext db) : base(db) { } + + private DbSet Layers => Db.Set(); + + // --- Lists ----------------------------------------------------------------------------------- + + private IQueryable Filtered(ItemListCriteria c) + { + var q = Set.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(c.Search)) + { + var term = c.Search.Trim(); + q = q.Where(i => EF.Functions.Like(i.Sku, $"%{term}%") || EF.Functions.Like(i.Name, $"%{term}%")); + } + if (c.Status is not null) q = q.Where(i => i.Status == c.Status); + if (c.CategoryId is not null) q = q.Where(i => i.CategoryId == c.CategoryId); + if (c.SubCategoryId is not null) q = q.Where(i => i.SubCategoryId == c.SubCategoryId); + if (c.BrandId is not null) q = q.Where(i => i.BrandId == c.BrandId); + if (c.TrackingMode is not null) q = q.Where(i => i.TrackingMode == c.TrackingMode); + if (c.StockNature is not null) q = q.Where(i => i.StockNature == c.StockNature); + + var layers = Layers; + switch (c.Stock) + { + case ItemStockFilter.InStock: + q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0); + break; + case ItemStockFilter.OutOfStock: + q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0); + break; + case ItemStockFilter.LowStock: + q = q.Where(i => i.ReorderSettings.Any(rs => + (layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId) + .Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint)); + break; + } + return q; + } + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + ItemListCriteria criteria, int skip, int take, CancellationToken ct = default) + { + var q = Filtered(criteria); + var total = await q.CountAsync(ct); + var rows = await q.Include(i => i.TaxRate).OrderBy(i => i.Sku).Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public async Task<(IReadOnlyList Rows, int Total)> ListOverviewPagedAsync( + ItemListCriteria criteria, int skip, int take, CancellationToken ct = default) + { + var q = Filtered(criteria); + var total = await q.CountAsync(ct); + var rows = await q.Include(i => i.ReorderSettings).OrderBy(i => i.Sku).Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public async Task> GetStockByItemAsync( + IReadOnlyCollection itemIds, CancellationToken ct = default) + { + var ids = itemIds.ToList(); + return (await Layers.AsNoTracking() + .Where(l => ids.Contains(l.ItemId) && l.QtyRemaining != 0) + .GroupBy(l => l.ItemId) + .Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) }) + .ToListAsync(ct)) + .ToDictionary(x => x.ItemId, x => (x.Qty, x.Value)); + } + + public async Task> GetOnHandByItemWarehouseAsync( + IReadOnlyCollection itemIds, CancellationToken ct = default) + { + var ids = itemIds.ToList(); + return (await Layers.AsNoTracking() + .Where(l => ids.Contains(l.ItemId) && l.QtyRemaining != 0) + .GroupBy(l => new { l.ItemId, l.WarehouseId }) + .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) }) + .ToListAsync(ct)) + .ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty); + } + + public async Task> ListConfirmedPurchasesAsync( + IReadOnlyCollection itemIds, CancellationToken ct = default) + { + var ids = itemIds.ToList(); + return await Db.Set().AsNoTracking() + .Where(l => ids.Contains(l.ItemId) && l.Grn!.PostedAt != null) + .Select(l => new ItemGrnPurchaseRow(l.ItemId, l.Grn!.PostedAt!.Value, l.Grn.VendorId, l.Grn.Vendor!.Name)) + .ToListAsync(ct); + } + + public async Task> GetQtyOutSinceAsync( + IReadOnlyCollection itemIds, DateTime since, IReadOnlyCollection docTypes, CancellationToken ct = default) + { + var ids = itemIds.ToList(); + var types = docTypes.ToList(); + return await Db.Set().AsNoTracking() + .Where(l => ids.Contains(l.ItemId) && l.Direction == Direction.Out + && l.CreatedAt >= since && types.Contains(l.SourceDocType)) + .GroupBy(l => l.ItemId) + .Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) }) + .ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct); + } + + public async Task> ListPurchasePricesAsync(int itemId, int take, CancellationToken ct = default) + => await Db.Set().AsNoTracking() + .Where(l => l.ItemId == itemId && l.Grn!.PostedAt != null) + .OrderByDescending(l => l.Grn!.PostedAt).ThenByDescending(l => l.GrnLineId) + .Take(take) + .Select(l => new ItemPurchasePriceRow( + l.GrnId, l.Grn!.DocNo, l.Grn.PostedAt!.Value, l.Grn.VendorId, l.Grn.Vendor!.Name, l.Grn.Warehouse!.Code, + l.Qty, l.UnitCost, l.DiscountPct, l.NetUnitCost, l.VatPct)) + .ToListAsync(ct); + + // --- Single item ----------------------------------------------------------------------------- + + public Task ExistsAsync(int itemId, CancellationToken ct = default) + => Set.AnyAsync(i => i.ItemId == itemId, ct); + + public Task GetWithDetailsAsync(int itemId, bool tracking, CancellationToken ct = default) + { + var q = Set.Include(i => i.ReorderSettings).Include(i => i.TaxRate); + return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(i => i.ItemId == itemId, ct); + } + + public Task SkuExistsAsync(string sku, int? excludeItemId = null, CancellationToken ct = default) + => Set.AnyAsync(i => i.Sku == sku && (excludeItemId == null || i.ItemId != excludeItemId), ct); + + public Task BarcodeExistsAsync(string barcode, int? excludeItemId = null, CancellationToken ct = default) + => Set.AnyAsync(i => i.Barcode == barcode && (excludeItemId == null || i.ItemId != excludeItemId), ct); + + public async Task HasStockHistoryAsync(int itemId, CancellationToken ct = default) + => await Layers.AnyAsync(l => l.ItemId == itemId, ct) + || await Db.Set().AnyAsync(l => l.ItemId == itemId, ct); + + public Task GetPriceAdjustmentLevelsAsync(CancellationToken ct = default) + => Db.Set().AsNoTracking() + .Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId) + .Select(c => (int?)c.PriceAdjustmentLevels) + .FirstOrDefaultAsync(ct); + + // --- Referenced masters ---------------------------------------------------------------------- + + public Task CategoryExistsAsync(int categoryId, CancellationToken ct = default) + => Db.Set().AnyAsync(c => c.CategoryId == categoryId, ct); + + public Task GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default) + => Db.Set().AsNoTracking().FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct); + + public Task GetBrandAsync(int brandId, CancellationToken ct = default) + => Db.Set().AsNoTracking().FirstOrDefaultAsync(b => b.BrandId == brandId, ct); + + public Task UomExistsAsync(int uomId, CancellationToken ct = default) + => Db.Set().AnyAsync(u => u.UomId == uomId, ct); + + public Task GetVendorAsync(int vendorId, CancellationToken ct = default) + => Db.Set().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct); + + public Task GetTaxRateAsync(int taxRateId, CancellationToken ct = default) + => Db.Set().AsNoTracking().FirstOrDefaultAsync(t => t.TaxRateId == taxRateId, ct); + + public Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default) + => Db.Set().AnyAsync(w => w.WarehouseId == warehouseId, ct); + + // --- Scanning -------------------------------------------------------------------------------- + + public Task FindItemByCodeAsync(string code, CancellationToken ct = default) + => Set.AsNoTracking().FirstOrDefaultAsync(i => i.Barcode == code || i.Sku == code, ct); + + public Task FindBatchByCodeAsync(string code, int? itemId = null, CancellationToken ct = default) + => Db.Set().AsNoTracking().Include(b => b.Item) + .FirstOrDefaultAsync(b => (itemId == null || b.ItemId == itemId) && (b.Barcode == code || b.BatchNo == code), ct); + + public Task FindSerialByCodeAsync(string code, int? itemId = null, CancellationToken ct = default) + => Db.Set().AsNoTracking().Include(s => s.Item) + .FirstOrDefaultAsync(s => (itemId == null || s.ItemId == itemId) && (s.Barcode == code || s.SerialNo == code), ct); +} diff --git a/Backend/ERPCore/Repositories/ItemTypeRepository.cs b/Backend/ERPCore/Repositories/ItemTypeRepository.cs new file mode 100644 index 0000000..8d1d9b5 --- /dev/null +++ b/Backend/ERPCore/Repositories/ItemTypeRepository.cs @@ -0,0 +1,38 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class ItemTypeRepository : Repository, IItemTypeRepository +{ + public ItemTypeRepository(ErpDbContext db) : base(db) { } + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default) + { + var q = Set.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(t => EF.Functions.Like(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(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public Task GetForReadAsync(int itemTypeId, CancellationToken ct = default) + => Set.AsNoTracking().FirstOrDefaultAsync(t => t.ItemTypeId == itemTypeId, ct); + + public Task NameExistsAsync(string name, int? excludeItemTypeId = null, CancellationToken ct = default) + { + var lowered = name.ToLower(); + return Set.AnyAsync(t => t.Name.ToLower() == lowered && (excludeItemTypeId == null || t.ItemTypeId != excludeItemTypeId), ct); + } +} diff --git a/Backend/ERPCore/Repositories/NumberSequenceRepository.cs b/Backend/ERPCore/Repositories/NumberSequenceRepository.cs new file mode 100644 index 0000000..bb77506 --- /dev/null +++ b/Backend/ERPCore/Repositories/NumberSequenceRepository.cs @@ -0,0 +1,71 @@ +using System.Data; +using ERPCore.Domain.Entities; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ERPCore.Repositories; + +/// +/// EF / ADO.NET implementation of . The bump is a single +/// INSERT … ON DUPLICATE KEY UPDATE … RETURNING so concurrent issues for the same (docType, branchId, year) +/// cannot get the same number (the upsert holds an exclusive lock on the row until commit). It runs as a direct +/// ADO.NET command enlisted in the DbContext's current transaction; EF's SqlQuery is avoided because it +/// wraps the statement in a subquery, which is not valid for a data-modifying statement. +/// +/// +/// MariaDB 10.5+ supports RETURNING on an upsert, so this stays a single atomic +/// statement exactly as the PostgreSQL version was — which is what keeps the callers that +/// run outside a UoW transaction safe. Do NOT "optimise" this into the one-statement +/// LAST_INSERT_ID() trick: this table has an AUTO_INCREMENT surrogate key, so on the +/// insert branch the engine's own auto-increment value clobbers the explicit one and the +/// first document of a (docType, branch, year) would be numbered with the sequence table's +/// PK. Splitting it into INSERT-then-SELECT is also wrong here, because the non-transacted +/// callers would then race. Side note: the surrogate AUTO_INCREMENT burns a value on the +/// duplicate path, so sequence_id has gaps — harmless, do not "fix" it. +/// ON DUPLICATE KEY UPDATE fires on any unique-key collision; today the only unique +/// keys are the never-supplied PK and IX(doc_type, branch_id, year), so it is unambiguous. +/// Adding another unique index to this table would silently change which row gets updated. +/// +public sealed class NumberSequenceRepository : INumberSequenceRepository +{ + private readonly ErpDbContext _db; + + public NumberSequenceRepository(ErpDbContext db) => _db = db; + + public Task GetBranchCodeAsync(int branchId, CancellationToken ct = default) + => _db.Set().AsNoTracking() + .Where(b => b.BranchId == branchId).Select(b => (string?)b.Code).FirstOrDefaultAsync(ct); + + public async Task IncrementAsync(string docType, int branchId, int year, CancellationToken ct = default) + { + var conn = _db.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) + await conn.OpenAsync(ct); + + await using var cmd = conn.CreateCommand(); + cmd.Transaction = _db.Database.CurrentTransaction?.GetDbTransaction(); + cmd.CommandText = """ + INSERT INTO `number_sequences` (`doc_type`, `branch_id`, `year`, `last_number`) + VALUES (@docType, @branchId, @year, 1) + ON DUPLICATE KEY UPDATE `last_number` = `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}'."); + return Convert.ToInt64(result); + } + + private static void AddParam(IDbCommand cmd, string name, object value) + { + var p = cmd.CreateParameter(); + p.ParameterName = name; + p.Value = value; + cmd.Parameters.Add(p); + } +} diff --git a/Backend/ERPCore/Repositories/OpeningStockRepository.cs b/Backend/ERPCore/Repositories/OpeningStockRepository.cs new file mode 100644 index 0000000..5ff21de --- /dev/null +++ b/Backend/ERPCore/Repositories/OpeningStockRepository.cs @@ -0,0 +1,58 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class OpeningStockRepository : Repository, IOpeningStockRepository +{ + public OpeningStockRepository(ErpDbContext db) : base(db) { } + + public async Task> ListStockRunsAsync(CancellationToken ct = default) + => (await Set.AsNoTracking() + .Where(r => r.Kind == OpeningBalanceKind.Stock) + .OrderByDescending(r => r.OpeningBalanceRunId) + .Select(r => new { Run = r, LineCount = r.StockLines.Count }) + .ToListAsync(ct)) + .Select(x => (x.Run, x.LineCount)).ToList(); + + public Task GetStockRunAsync(int runId, CancellationToken ct = default) + => Set.AsNoTracking() + .Include(r => r.StockLines).ThenInclude(l => l.Item) + .Include(r => r.StockLines).ThenInclude(l => l.Warehouse) + .FirstOrDefaultAsync(r => r.OpeningBalanceRunId == runId && r.Kind == OpeningBalanceKind.Stock, ct); + + public async Task> GetItemsAsync(IReadOnlyCollection itemIds, CancellationToken ct = default) + { + var ids = itemIds.ToList(); + return await Db.Set().AsNoTracking().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct); + } + + public async Task> GetWarehousesAsync(IReadOnlyCollection warehouseIds, CancellationToken ct = default) + { + var ids = warehouseIds.ToList(); + return await Db.Set().AsNoTracking().Where(w => ids.Contains(w.WarehouseId)).ToDictionaryAsync(w => w.WarehouseId, ct); + } + + public async Task> ListMovedPairsAsync( + IReadOnlyCollection itemIds, IReadOnlyCollection warehouseIds, CancellationToken ct = default) + { + var items = itemIds.ToList(); + var warehouses = warehouseIds.ToList(); + return (await Db.Set().AsNoTracking() + .Where(l => items.Contains(l.ItemId) && warehouses.Contains(l.WarehouseId)) + .Select(l => new { l.ItemId, l.WarehouseId }) + .Distinct() + .ToListAsync(ct)) + .Select(x => (x.ItemId, x.WarehouseId)).ToList(); + } + + public async Task AddLedgerAsync(StockLedger row, CancellationToken ct = default) + => await Db.Set().AddAsync(row, ct); + + public async Task AddJournalStubAsync(JournalEntryStub stub, CancellationToken ct = default) + => await Db.Set().AddAsync(stub, ct); +} diff --git a/Backend/ERPCore/Repositories/PriceAdjustmentRepository.cs b/Backend/ERPCore/Repositories/PriceAdjustmentRepository.cs new file mode 100644 index 0000000..96f4092 --- /dev/null +++ b/Backend/ERPCore/Repositories/PriceAdjustmentRepository.cs @@ -0,0 +1,94 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class PriceAdjustmentRepository : Repository, IPriceAdjustmentRepository +{ + public PriceAdjustmentRepository(ErpDbContext db) : base(db) { } + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, PriceAdjustmentStatus? status, int? itemId, int skip, int take, CancellationToken ct = default) + { + var q = Set.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || EF.Functions.Like(p.Reason, $"%{term}%")); + } + if (status is not null) q = q.Where(p => p.Status == status); + if (itemId is not null) q = q.Where(p => p.Lines.Any(l => l.ItemId == itemId)); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(p => p.PriceAdjustmentId) + .Skip(skip).Take(take) + .Select(p => new PriceAdjustmentListRow( + p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals, + p.Lines.Count, p.CreatedBy, p.Creator!.DisplayName, p.CreatedAt, p.AppliedAt)) + .ToListAsync(ct); + return (rows, total); + } + + public Task GetWithDetailsAsync(int priceAdjustmentId, CancellationToken ct = default) + => Set.AsNoTracking() + .Include(p => p.Lines).ThenInclude(l => l.Item) + .Include(p => p.Creator) + .FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct); + + public Task GetForDecisionAsync(int priceAdjustmentId, CancellationToken ct = default) + => Set.Include(p => p.Lines).FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct); + + public async Task> GetItemsForUpdateAsync(IReadOnlyCollection itemIds, CancellationToken ct = default) + { + var ids = itemIds.ToList(); + return await Db.Set().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct); + } + + public async Task> ListPendingRequestsForItemsAsync( + IReadOnlyCollection itemIds, CancellationToken ct = default) + { + var ids = itemIds.ToList(); + // Queried from the line side (not PriceAdjustment.Lines via SelectMany) so this stays a + // plain join — a SelectMany over a to-many navigation generates a CROSS APPLY that + // Pomelo/MySQL can't translate (it works fine against PostgreSQL, which is what this + // predates — 2026-10-16 MySQL-port regression). + return (await Db.Set().AsNoTracking() + .Where(l => ids.Contains(l.ItemId) + && (l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingApproval + || l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingSecondApproval)) + .Select(l => new { l.ItemId, DocNo = l.PriceAdjustment!.DocNo }) + .ToListAsync(ct)) + .Select(x => (x.ItemId, x.DocNo)).ToList(); + } + + public async Task AddHistoryAsync(ItemPriceHistory history, CancellationToken ct = default) + => await Db.Set().AddAsync(history, ct); + + public async Task> ListItemHistoryAsync(int itemId, CancellationToken ct = default) + => await Db.Set().AsNoTracking() + .Where(h => h.ItemId == itemId) + .OrderByDescending(h => h.AppliedAt).ThenByDescending(h => h.ItemPriceHistoryId) + .ToListAsync(ct); + + public async Task> GetUserNamesAsync(IReadOnlyCollection userIds, CancellationToken ct = default) + { + var ids = userIds.ToList(); + return await Db.Set().AsNoTracking().Where(u => ids.Contains(u.UserId)).ToDictionaryAsync(u => u.UserId, u => u.DisplayName, ct); + } + + public async Task> GetDocNosAsync(IReadOnlyCollection priceAdjustmentIds, CancellationToken ct = default) + { + var ids = priceAdjustmentIds.ToList(); + return await Set.AsNoTracking().Where(p => ids.Contains(p.PriceAdjustmentId)).ToDictionaryAsync(p => p.PriceAdjustmentId, p => p.DocNo, ct); + } + + public Task GetPriceAdjustmentLevelsAsync(CancellationToken ct = default) + => Db.Set().AsNoTracking() + .Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId) + .Select(c => (int?)c.PriceAdjustmentLevels) + .FirstOrDefaultAsync(ct); +} diff --git a/Backend/ERPCore/Repositories/ProcurementConfigRepository.cs b/Backend/ERPCore/Repositories/ProcurementConfigRepository.cs new file mode 100644 index 0000000..c4baac0 --- /dev/null +++ b/Backend/ERPCore/Repositories/ProcurementConfigRepository.cs @@ -0,0 +1,15 @@ +using ERPCore.Domain.Entities; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class ProcurementConfigRepository : Repository, IProcurementConfigRepository +{ + public ProcurementConfigRepository(ErpDbContext db) : base(db) { } + + public Task GetForReadAsync(CancellationToken ct = default) + => Set.AsNoTracking().FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct); +} diff --git a/Backend/ERPCore/Repositories/ProductConfigRepository.cs b/Backend/ERPCore/Repositories/ProductConfigRepository.cs new file mode 100644 index 0000000..139cf37 --- /dev/null +++ b/Backend/ERPCore/Repositories/ProductConfigRepository.cs @@ -0,0 +1,15 @@ +using ERPCore.Domain.Entities; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class ProductConfigRepository : Repository, IProductConfigRepository +{ + public ProductConfigRepository(ErpDbContext db) : base(db) { } + + public Task GetForReadAsync(CancellationToken ct = default) + => Set.AsNoTracking().FirstOrDefaultAsync(c => c.ConfigId == ProductConfig.SingletonId, ct); +} diff --git a/Backend/ERPCore/Repositories/PurchaseInvoicePaymentRepository.cs b/Backend/ERPCore/Repositories/PurchaseInvoicePaymentRepository.cs new file mode 100644 index 0000000..beaf3b1 --- /dev/null +++ b/Backend/ERPCore/Repositories/PurchaseInvoicePaymentRepository.cs @@ -0,0 +1,24 @@ +using ERPCore.Domain.Entities; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class PurchaseInvoicePaymentRepository : Repository, IPurchaseInvoicePaymentRepository +{ + public PurchaseInvoicePaymentRepository(ErpDbContext db) : base(db) { } + + public Task InvoiceExistsAsync(int purchaseInvoiceId, CancellationToken ct = default) + => Db.Set().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct); + + public Task GetInvoiceForUpdateAsync(int purchaseInvoiceId, CancellationToken ct = default) + => Db.Set().FirstOrDefaultAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct); + + public async Task> ListForInvoiceAsync(int purchaseInvoiceId, CancellationToken ct = default) + => await Set.AsNoTracking() + .Where(p => p.PurchaseInvoiceId == purchaseInvoiceId) + .OrderByDescending(p => p.PurchaseInvoicePaymentId) + .ToListAsync(ct); +} diff --git a/Backend/ERPCore/Repositories/PurchaseInvoiceRepository.cs b/Backend/ERPCore/Repositories/PurchaseInvoiceRepository.cs new file mode 100644 index 0000000..c870725 --- /dev/null +++ b/Backend/ERPCore/Repositories/PurchaseInvoiceRepository.cs @@ -0,0 +1,65 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class PurchaseInvoiceRepository : Repository, IPurchaseInvoiceRepository +{ + public PurchaseInvoiceRepository(ErpDbContext db) : base(db) { } + + private DbSet Grns => Db.Set(); + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + string? search, PurchaseInvoiceStatus? status, int? vendorId, int skip, int take, CancellationToken ct = default) + { + IQueryable q = Set.AsNoTracking().Include(x => x.Lines); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%") + || EF.Functions.Like(x.VendorInvoiceNo, $"%{term}%") + || EF.Functions.Like(x.VendorSnapshotName, $"%{term}%")); + } + if (status is not null) q = q.Where(x => x.Status == status); + if (vendorId is not null) q = q.Where(x => x.VendorId == vendorId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.PurchaseInvoiceId).Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + public Task GetWithLinesAsync(int purchaseInvoiceId, bool tracking, CancellationToken ct = default) + { + var q = Set.Include(x => x.Lines).ThenInclude(l => l.Grn); + return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct); + } + + public Task GetVendorAsync(int vendorId, CancellationToken ct = default) + => Db.Set().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct); + + public Task VendorInvoiceNoExistsAsync(int vendorId, string vendorInvoiceNo, int? excludePurchaseInvoiceId, CancellationToken ct = default) + => Set.AsNoTracking().AnyAsync(x => + x.VendorId == vendorId + && x.Status != PurchaseInvoiceStatus.Cancelled + && x.VendorInvoiceNo == vendorInvoiceNo + && (excludePurchaseInvoiceId == null || x.PurchaseInvoiceId != excludePurchaseInvoiceId), ct); + + public async Task> ListGrnsForInvoicingAsync(IReadOnlyCollection grnIds, CancellationToken ct = default) + { + var ids = grnIds.ToList(); + return await Grns.Include(g => g.Lines).Where(g => ids.Contains(g.GrnId)).ToListAsync(ct); + } + + public async Task> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default) + => await Grns.AsNoTracking() + .Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null) + .OrderBy(g => g.PostedAt) + .Select(g => new EligibleGrnRow( + g.GrnId, g.DocNo, g.PostedAt ?? g.CreatedAt, g.WarehouseId, g.Lines.Count, + g.Lines.Sum(l => l.ReceivedValue), g.Lines.Sum(l => l.VatAmount), g.Lines.Sum(l => l.LineTotal))) + .ToListAsync(ct); +} diff --git a/Backend/ERPCore/Repositories/PurchaseOrderRepository.cs b/Backend/ERPCore/Repositories/PurchaseOrderRepository.cs new file mode 100644 index 0000000..49c4bbe --- /dev/null +++ b/Backend/ERPCore/Repositories/PurchaseOrderRepository.cs @@ -0,0 +1,99 @@ +using ERPCore.Domain.Entities; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class PurchaseOrderRepository : Repository, IPurchaseOrderRepository +{ + public PurchaseOrderRepository(ErpDbContext db) : base(db) { } + + private DbSet Vendors => Db.Set(); + private DbSet Warehouses => Db.Set(); + + public async Task<(IReadOnlyList Rows, int Total)> ListPagedAsync( + PurchaseOrderListCriteria criteria, int skip, int take, CancellationToken ct = default) + { + var q = Set.AsNoTracking().Include(p => p.Lines).AsQueryable(); + if (!string.IsNullOrWhiteSpace(criteria.Search)) + { + var term = criteria.Search.Trim(); + // Doc no., or the vendor's name / code (the vendor is the table's first column). + var vendorIdsMatching = Vendors + .Where(v => EF.Functions.Like(v.Name, $"%{term}%") || EF.Functions.Like(v.Code, $"%{term}%")) + .Select(v => v.VendorId); + q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || vendorIdsMatching.Contains(p.VendorId)); + } + if (criteria.Status is not null) q = q.Where(p => p.Status == criteria.Status); + if (criteria.VendorId is not null) q = q.Where(p => p.VendorId == criteria.VendorId); + if (criteria.CreatedFrom is { } start) q = q.Where(p => p.CreatedAt >= start); + if (criteria.CreatedBefore is { } end) q = q.Where(p => p.CreatedAt < end); + + var total = await q.CountAsync(ct); + var rows = await ApplySort(q, criteria.Sort).Skip(skip).Take(take).ToListAsync(ct); + return (rows, total); + } + + private IQueryable ApplySort(IQueryable q, string? sort) + { + var key = (sort ?? "-created").Trim(); + var desc = key.StartsWith('-'); + key = key.TrimStart('-', '+').ToLowerInvariant(); + + IOrderedQueryable ordered = key switch + { + "vendor" => desc + ? q.OrderByDescending(p => Vendors.Where(v => v.VendorId == p.VendorId).Select(v => v.Name).FirstOrDefault()) + : q.OrderBy(p => Vendors.Where(v => v.VendorId == p.VendorId).Select(v => v.Name).FirstOrDefault()), + "total" => desc + ? q.OrderByDescending(p => p.Lines.Sum(l => l.Qty * l.UnitPrice + l.TaxAmount)) + : q.OrderBy(p => p.Lines.Sum(l => l.Qty * l.UnitPrice + l.TaxAmount)), + "status" => desc ? q.OrderByDescending(p => p.Status) : q.OrderBy(p => p.Status), + "created" => desc ? q.OrderByDescending(p => p.CreatedAt) : q.OrderBy(p => p.CreatedAt), + _ => q.OrderByDescending(p => p.CreatedAt) + }; + return ordered.ThenByDescending(p => p.PoId); + } + + public Task GetWithLinesAsync(int poId, bool tracking, CancellationToken ct = default) + { + var q = Set.Include(p => p.Lines); + return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(p => p.PoId == poId, ct); + } + + public Task GetVendorAsync(int vendorId, CancellationToken ct = default) + => Vendors.AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct); + + public async Task> GetVendorsAsync(IReadOnlyCollection vendorIds, CancellationToken ct = default) + { + var ids = vendorIds.ToList(); + return await Vendors.AsNoTracking().Where(v => ids.Contains(v.VendorId)).ToDictionaryAsync(v => v.VendorId, ct); + } + + public Task RequisitionExistsAsync(int requisitionId, CancellationToken ct = default) + => Db.Set().AnyAsync(r => r.RequisitionId == requisitionId, ct); + + public async Task> ListExistingItemIdsAsync(IReadOnlyCollection itemIds, CancellationToken ct = default) + { + var ids = itemIds.ToList(); + return await Db.Set().Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct); + } + + public async Task> ListExistingWarehouseIdsAsync(IReadOnlyCollection warehouseIds, CancellationToken ct = default) + { + var ids = warehouseIds.ToList(); + return await Warehouses.Where(w => ids.Contains(w.WarehouseId)).Select(w => w.WarehouseId).ToListAsync(ct); + } + + public async Task> ListWarehousesOutsideBranchAsync( + IReadOnlyCollection warehouseIds, int branchId, CancellationToken ct = default) + { + var ids = warehouseIds.ToList(); + return await Warehouses.AsNoTracking() + .Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId) + .Select(w => w.WarehouseId) + .ToListAsync(ct); + } +} diff --git a/Backend/ERPCore/Repositories/PurchaseReturnRepository.cs b/Backend/ERPCore/Repositories/PurchaseReturnRepository.cs new file mode 100644 index 0000000..cc7aeb5 --- /dev/null +++ b/Backend/ERPCore/Repositories/PurchaseReturnRepository.cs @@ -0,0 +1,78 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Repositories; + +/// EF implementation of . +public sealed class PurchaseReturnRepository : Repository, IPurchaseReturnRepository +{ + public PurchaseReturnRepository(ErpDbContext db) : base(db) { } + + private DbSet Layers => Db.Set(); + + public async Task<(IReadOnlyList<(PurchaseReturn Return, int LineCount)> Rows, int Total)> ListPagedAsync( + string? search, int? vendorId, int? warehouseId, int skip, int take, CancellationToken ct = default) + { + var q = Set.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + q = q.Where(r => EF.Functions.Like(r.DocNo, $"%{term}%")); + } + if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId); + if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.ReturnId).Skip(skip).Take(take) + .Select(r => new { Return = r, LineCount = r.Lines.Count }) + .ToListAsync(ct); + return (rows.Select(x => (x.Return, x.LineCount)).ToList(), total); + } + + public Task GetWithLinesAsync(int returnId, CancellationToken ct = default) + => Set.AsNoTracking().Include(r => r.Lines).FirstOrDefaultAsync(r => r.ReturnId == returnId, ct); + + public async Task> ListLedgerIdsAsync(int returnId, CancellationToken ct = default) + => await Db.Set().AsNoTracking() + .Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId) + .OrderBy(l => l.LedgerId) + .Select(l => l.LedgerId) + .ToListAsync(ct); + + // --- References ------------------------------------------------------------------------------ + + public Task VendorExistsAsync(int vendorId, CancellationToken ct = default) + => Db.Set().AnyAsync(v => v.VendorId == vendorId, ct); + + public Task WarehouseExistsAsync(int warehouseId, CancellationToken ct = default) + => Db.Set().AnyAsync(w => w.WarehouseId == warehouseId, ct); + + public Task GetReasonCodeAsync(int reasonCodeId, CancellationToken ct = default) + => Db.Set().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == reasonCodeId, ct); + + public Task GetItemAsync(int itemId, CancellationToken ct = default) + => Db.Set().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == itemId, ct); + + public Task GetGrnLineAsync(int grnLineId, CancellationToken ct = default) + => Db.Set().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == grnLineId, ct); + + // --- Rejected GRN-line stock ----------------------------------------------------------------- + + public async Task SumRemainingOnGrnLineAsync(int grnLineId, CancellationToken ct = default) + => await Layers.AsNoTracking().Where(l => l.GrnLineId == grnLineId).SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + + public Task> ListOpenLayersForGrnLineAsync(int grnLineId, CancellationToken ct = default) + => Layers + .Where(l => l.GrnLineId == grnLineId && l.QtyRemaining > 0) + .OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId) + .ToListAsync(ct); + + public async Task> ListSerialsAsync(IReadOnlyCollection serialIds, CancellationToken ct = default) + { + var ids = serialIds.ToList(); + return await Db.Set().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct); + } +} diff --git a/Backend/ERPCore/Services/BrandService.cs b/Backend/ERPCore/Services/BrandService.cs index b736e48..9b9c6ab 100644 --- a/Backend/ERPCore/Services/BrandService.cs +++ b/Backend/ERPCore/Services/BrandService.cs @@ -7,7 +7,7 @@ using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IBrandRepository namespace ERPCore.Services; @@ -17,10 +17,10 @@ namespace ERPCore.Services; /// public sealed class BrandService : IBrandService { - private readonly IRepository _brands; + private readonly IBrandRepository _brands; private readonly IUnitOfWork _uow; - public BrandService(IRepository brands, IUnitOfWork uow) + public BrandService(IBrandRepository brands, IUnitOfWork uow) { _brands = brands; _uow = uow; @@ -28,45 +28,36 @@ public sealed class BrandService : IBrandService public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) { - var q = _brands.Query().AsNoTracking(); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(b => EF.Functions.Like(b.Name, $"%{term}%")); - } - if (status is not null) q = q.Where(b => b.Status == status); - - var total = await q.CountAsync(ct); - var rows = await q.OrderBy(b => b.Name) - .Skip(query.Skip).Take(query.PageSize) - .Select(b => new BrandDto(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt)) - .ToListAsync(ct); - - return PagedResponse.Create(rows, query.Page, query.PageSize, total); + var (rows, total) = await _brands.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct); + return PagedResponse.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); } public async Task?> GetAsync(int brandId, CancellationToken ct = default) { - var brand = await _brands.Query().AsNoTracking() - .FirstOrDefaultAsync(b => b.BrandId == brandId, ct); + var brand = await _brands.GetForReadAsync(brandId, ct); return brand is null ? null : new ETagged(Map(brand), brand.RowVersion); } public async Task> CreateAsync(CreateBrandRequest request, CancellationToken ct = default) { var name = request.Name.Trim(); - if (await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower(), ct)) - throw new ConflictException($"A brand named '{name}' already exists."); - var brand = new Brand + // Same shape as BankDepositService.CreateAsync: check, build and add inside one transaction + // (ExecuteInTransactionAsync saves and commits on success), then map the returned entity. + var brand = await _uow.ExecuteInTransactionAsync(async token => { - Name = name, - Status = EntityStatus.Active, - CreatedAt = DateTime.UtcNow - }; + if (await _brands.NameExistsAsync(name, ct: token)) + throw new ConflictException($"A brand named '{name}' already exists."); - await _brands.AddAsync(brand, ct); - await _uow.SaveChangesAsync(ct); + var entity = new Brand + { + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + await _brands.AddAsync(entity, token); + return entity; + }, ct); return new ETagged(Map(brand), brand.RowVersion); } @@ -81,16 +72,19 @@ public sealed class BrandService : IBrandService throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412); var name = request.Name.Trim(); - if (!string.Equals(brand.Name, name, StringComparison.Ordinal) - && await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower() && b.BrandId != brandId, ct)) - throw new ConflictException($"A brand named '{name}' already exists."); - - brand.Name = name; - brand.UpdatedAt = DateTime.UtcNow; try { - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(async token => + { + if (!string.Equals(brand.Name, name, StringComparison.Ordinal) + && await _brands.NameExistsAsync(name, brandId, token)) + throw new ConflictException($"A brand named '{name}' already exists."); + + brand.Name = name; + brand.UpdatedAt = DateTime.UtcNow; + return 0; + }, ct); } catch (DbUpdateConcurrencyException) { @@ -105,9 +99,12 @@ public sealed class BrandService : IBrandService var brand = await _brands.GetByIdAsync(brandId, ct) ?? throw new NotFoundException($"Brand {brandId} was not found."); - brand.Status = status; - brand.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(token => + { + brand.Status = status; + brand.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); } private static BrandDto Map(Brand b) => new(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt); diff --git a/Backend/ERPCore/Services/BundleSaleService.cs b/Backend/ERPCore/Services/BundleSaleService.cs index fc5ec52..2cd8dd3 100644 --- a/Backend/ERPCore/Services/BundleSaleService.cs +++ b/Backend/ERPCore/Services/BundleSaleService.cs @@ -3,101 +3,55 @@ using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; using ERPCore.Dtos.Common; using ERPCore.Dtos.Sales; -using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.Services.Stock; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; +/// +/// Bundle sales and bundle templates. Every query goes through ; +/// every write runs inside (which saves and commits +/// on success), the same shape as — build/change inside, map after. +/// public sealed class BundleSaleService : IBundleSaleService { - private readonly IRepository _templates; - private readonly IRepository _bundles; - private readonly IRepository _dayEnds; - private readonly IRepository _customers; - private readonly IRepository _items; - private readonly IRepository _warehouses; - private readonly IRepository _users; + private readonly IBundleSaleRepository _bundles; private readonly ISalesDomainService _sales; private readonly ISalesPostingService _posting; - private readonly ICurrentUser _currentUser; private readonly INumberSequenceService _numbers; private readonly IUnitOfWork _uow; public BundleSaleService( - IRepository bundles, - IRepository templates, - IRepository dayEnds, - IRepository customers, - IRepository items, - IRepository warehouses, - IRepository users, + IBundleSaleRepository bundles, ISalesDomainService sales, ISalesPostingService posting, - ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) { - _templates = templates; _bundles = bundles; - _dayEnds = dayEnds; - _customers = customers; - _items = items; - _warehouses = warehouses; - _users = users; _sales = sales; _posting = posting; - _currentUser = currentUser; _numbers = numbers; _uow = uow; } public async Task> ListTemplatesAsync(PageQuery query, CancellationToken ct = default) { - IQueryable q = _templates.Query().AsNoTracking().Include(x => x.Lines); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(x => EF.Functions.Like(x.TemplateCode, $"%{term}%") || EF.Functions.Like(x.TemplateName, $"%{term}%") || EF.Functions.Like(x.Description ?? "", $"%{term}%")); - } - var total = await q.CountAsync(ct); - var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); - return PagedResponse.Create(rows.Select(x => new BundleSaleTemplateSummaryDto( - x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.BundlePrice, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total); + var (rows, total) = await _bundles.ListTemplatesPagedAsync(query.Q, query.Skip, query.PageSize, ct); + return PagedResponse.Create(rows.Select(MapTemplateSummary).ToList(), query.Page, query.PageSize, total); } public async Task GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default) { - var template = await _templates.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleTemplateId == bundleSaleTemplateId, ct); - return template is null ? null : new BundleSaleTemplateDto( - template.BundleSaleTemplateId, - template.TemplateCode, - template.TemplateName, - template.Description, - template.BundlePrice, - template.Status, - template.CreatedAt, - template.UpdatedAt, - template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto( - x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList()); + var template = await _bundles.GetTemplateForReadAsync(bundleSaleTemplateId, ct); + return template is null ? null : MapTemplate(template); } public async Task CreateTemplateAsync(CreateBundleSaleTemplateRequest request, CancellationToken ct = default) { - var template = new BundleSaleTemplate - { - TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, ct), - TemplateName = request.TemplateName, - Description = request.Description, - BundlePrice = request.BundlePrice ?? 0m, - Status = EntityStatus.Active, - CreatedAt = DateTime.UtcNow - }; - if (request.BundlePrice is { } bundlePrice) { if (!request.Lines.Any(l => l.IncludeInBundle)) @@ -105,32 +59,41 @@ public sealed class BundleSaleService : IBundleSaleService AllocateBundlePrice(request.Lines, bundlePrice); } - foreach (var line in request.Lines) + var template = await _uow.ExecuteInTransactionAsync(async token => { - if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct)) - throw new NotFoundException($"Item {line.ItemId} was not found."); - if (!await _warehouses.Query().AsNoTracking().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct)) - throw new NotFoundException($"Warehouse {line.WarehouseId} was not found."); - - template.Lines.Add(new BundleSaleTemplateLine + var entity = new BundleSaleTemplate { - ItemId = line.ItemId, - WarehouseId = line.WarehouseId, - Qty = line.Qty, - UnitPrice = line.UnitPrice, - IncludeInBundle = line.IncludeInBundle, - SortOrder = line.SortOrder - }); - } + TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, token), + TemplateName = request.TemplateName, + Description = request.Description, + BundlePrice = request.BundlePrice ?? 0m, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; - await _templates.AddAsync(template, ct); - await _uow.SaveChangesAsync(ct); + foreach (var line in request.Lines) + { + if (!await _bundles.ItemExistsAsync(line.ItemId, token)) + throw new NotFoundException($"Item {line.ItemId} was not found."); + if (!await _bundles.WarehouseExistsAsync(line.WarehouseId, token)) + throw new NotFoundException($"Warehouse {line.WarehouseId} was not found."); - return new BundleSaleTemplateDto( - template.BundleSaleTemplateId, template.TemplateCode, template.TemplateName, template.Description, template.BundlePrice, - template.Status, template.CreatedAt, template.UpdatedAt, - template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto( - x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList()); + entity.Lines.Add(new BundleSaleTemplateLine + { + ItemId = line.ItemId, + WarehouseId = line.WarehouseId, + Qty = line.Qty, + UnitPrice = line.UnitPrice, + IncludeInBundle = line.IncludeInBundle, + SortOrder = line.SortOrder + }); + } + + await _bundles.AddTemplateAsync(entity, token); + return entity; + }, ct); + + return MapTemplate(template); } /// @@ -161,23 +124,13 @@ public sealed class BundleSaleService : IBundleSaleService public async Task> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) { - IQueryable q = _bundles.Query().AsNoTracking().Include(x => x.Lines); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(x => EF.Functions.Like(x.BundleNo, $"%{term}%") || EF.Functions.Like(x.BundleName, $"%{term}%") || EF.Functions.Like(x.BundleCode, $"%{term}%")); - } - if (status is not null) q = q.Where(x => x.Status == status); - if (customerId is not null) q = q.Where(x => x.CustomerId == customerId); - if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); - var total = await q.CountAsync(ct); - var rows = await q.OrderByDescending(x => x.BundleSaleId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); + var (rows, total) = await _bundles.ListPagedAsync(query.Q, status, customerId, warehouseId, query.Skip, query.PageSize, ct); return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); } public async Task GetAsync(int bundleSaleId, CancellationToken ct = default) { - var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct); + var bundle = await _bundles.GetForReadAsync(bundleSaleId, ct); return bundle is null ? null : Map(bundle); } @@ -188,82 +141,96 @@ public sealed class BundleSaleService : IBundleSaleService { await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); - // Same guard as SalesSlipService.CreateAsync — a bundle sale is a cashier document - // exactly like a sales slip, so it's blocked by the same closed day (docs/14 Sales Day End). - var today = DateOnly.FromDateTime(DateTime.UtcNow); - var alreadyClosed = await _dayEnds.Query().AsNoTracking() - .AnyAsync(x => x.CashierUserId == request.CashierUserId && x.BusinessDate == today, ct); - if (alreadyClosed) - throw new ConflictException($"Cashier {request.CashierUserId} already closed today's ({today:yyyy-MM-dd}) sales — day-end has been posted."); - - var template = await _templates.Query().AsNoTracking().Include(x => x.Lines) - .FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct); - var bundle = new BundleSale + var bundle = await _uow.ExecuteInTransactionAsync(async token => { - BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, ct), - BundleDate = DateTime.UtcNow, - CustomerId = request.CustomerId, - WarehouseId = request.WarehouseId, - CashierUserId = request.CashierUserId, - BundleSaleTemplateId = request.BundleSaleTemplateId, - BundleName = request.BundleName, - BundleCode = string.Empty, - Status = BundleSaleStatus.Draft, - CreatedAt = DateTime.UtcNow - }; - bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); - bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct); - Recalculate(bundle, request.BundlePrice, request.CardAmount); - bundle.BundleCode = $"{bundle.BundleNo}-B"; - await _bundles.AddAsync(bundle, ct); - await _uow.SaveChangesAsync(ct); + // Same guard as SalesSlipService.CreateAsync — a bundle sale is a cashier document + // exactly like a sales slip, so it's blocked by the same closed day (docs/14 Sales Day End). + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (await _bundles.IsDayClosedAsync(request.CashierUserId, today, token)) + throw new ConflictException($"Cashier {request.CashierUserId} already closed today's ({today:yyyy-MM-dd}) sales — day-end has been posted."); + + var template = await RequireTemplateAsync(request.BundleSaleTemplateId, token); + var entity = new BundleSale + { + BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, token), + BundleDate = DateTime.UtcNow, + CustomerId = request.CustomerId, + WarehouseId = request.WarehouseId, + CashierUserId = request.CashierUserId, + BundleSaleTemplateId = request.BundleSaleTemplateId, + BundleName = request.BundleName, + BundleCode = string.Empty, + Status = BundleSaleStatus.Draft, + CreatedAt = DateTime.UtcNow + }; + entity.CustomerSnapshotName = await _bundles.GetCustomerSnapshotNameAsync(request.CustomerId, token); + entity.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, token); + Recalculate(entity, request.BundlePrice, request.CardAmount); + entity.BundleCode = $"{entity.BundleNo}-B"; + await _bundles.AddAsync(entity, token); + return entity; + }, ct); + return Map(bundle); } public async Task UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default) { - var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct) + var bundle = await _bundles.GetForUpdateAsync(bundleSaleId, ct) ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found."); if (bundle.Status != BundleSaleStatus.Draft) throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited."); await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); - var template = await _templates.Query().AsNoTracking().Include(x => x.Lines) - .FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct); - bundle.CustomerId = request.CustomerId; - bundle.WarehouseId = request.WarehouseId; - bundle.CashierUserId = request.CashierUserId; - bundle.BundleSaleTemplateId = request.BundleSaleTemplateId; - bundle.BundleName = request.BundleName; - bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); - bundle.Lines.Clear(); - foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line); - Recalculate(bundle, request.BundlePrice, request.CardAmount); - bundle.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + + await _uow.ExecuteInTransactionAsync(async token => + { + var template = await RequireTemplateAsync(request.BundleSaleTemplateId, token); + bundle.CustomerId = request.CustomerId; + bundle.WarehouseId = request.WarehouseId; + bundle.CashierUserId = request.CashierUserId; + bundle.BundleSaleTemplateId = request.BundleSaleTemplateId; + bundle.BundleName = request.BundleName; + bundle.CustomerSnapshotName = await _bundles.GetCustomerSnapshotNameAsync(request.CustomerId, token); + bundle.Lines.Clear(); + foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, token)) bundle.Lines.Add(line); + Recalculate(bundle, request.BundlePrice, request.CardAmount); + bundle.UpdatedAt = DateTime.UtcNow; + return 0; + }, ct); + return Map(bundle); } public async Task PostAsync(int bundleSaleId, CancellationToken ct = default) - { - await _posting.PostBundleAsync(bundleSaleId, ct); - var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines) - .FirstAsync(x => x.BundleSaleId == bundleSaleId, ct); - return Map(bundle); - } + { + await _posting.PostBundleAsync(bundleSaleId, ct); + var bundle = await _bundles.GetForReadAsync(bundleSaleId, ct) + ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found."); + return Map(bundle); + } public async Task CancelAsync(int bundleSaleId, CancellationToken ct = default) { - var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct) + var bundle = await _bundles.GetForUpdateAsync(bundleSaleId, ct) ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found."); if (bundle.Status != BundleSaleStatus.Draft) throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled."); - bundle.Status = BundleSaleStatus.Cancelled; - bundle.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + + await _uow.ExecuteInTransactionAsync(token => + { + bundle.Status = BundleSaleStatus.Cancelled; + bundle.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); + return Map(bundle); } + private async Task RequireTemplateAsync(int bundleSaleTemplateId, CancellationToken ct) + => await _bundles.GetTemplateForReadAsync(bundleSaleTemplateId, ct) + ?? throw new NotFoundException($"Bundle template {bundleSaleTemplateId} was not found."); + private async Task> BuildLinesAsync( BundleSaleTemplate template, int warehouseId, IReadOnlyList requestLines, CancellationToken ct) { @@ -289,13 +256,13 @@ public sealed class BundleSaleService : IBundleSaleService // Keep any per-line warehouse input from drifting away from the header. var lineWarehouseId = warehouseId; - var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); + var itemName = await _bundles.GetItemNameAsync(r.ItemId, ct); await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, lineWarehouseId, r.Qty, 0m, null, ct); var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false); lines.Add(new BundleSaleLine { ItemId = r.ItemId, - Description = item.Name, + Description = itemName, Qty = r.Qty, WarehouseId = lineWarehouseId, UnitPrice = r.UnitPrice, @@ -326,6 +293,14 @@ public sealed class BundleSaleService : IBundleSaleService bundle.CashAmount = bundle.GrandTotal - card; } + private static BundleSaleTemplateSummaryDto MapTemplateSummary(BundleSaleTemplate x) => new( + x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.BundlePrice, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt); + + private static BundleSaleTemplateDto MapTemplate(BundleSaleTemplate t) => new( + t.BundleSaleTemplateId, t.TemplateCode, t.TemplateName, t.Description, t.BundlePrice, t.Status, t.CreatedAt, t.UpdatedAt, + t.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto( + x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList()); + private static BundleSaleSummaryDto MapSummary(BundleSale x) => new( x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.GrandTotal, x.CreatedAt); diff --git a/Backend/ERPCore/Services/CashInHandService.cs b/Backend/ERPCore/Services/CashInHandService.cs index 98698ee..bdba907 100644 --- a/Backend/ERPCore/Services/CashInHandService.cs +++ b/Backend/ERPCore/Services/CashInHandService.cs @@ -1,12 +1,9 @@ using System.Globalization; -using ERPCore.Domain.Entities; -using ERPCore.Domain.Enums; using ERPCore.Dtos.Accounts; using ERPCore.Infra.Auth; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -17,30 +14,21 @@ namespace ERPCore.Services; /// BankDeposit:CashTrackingStartDate; the opening balance before a window is a handful of /// database sums, and the days inside it are bucketed by local day here (same reason as /// : MariaDB can't portably group by a zone-shifted date). +/// Every query lives in ; this service keeps the balance rules. /// public sealed class CashInHandService : ICashInHandService { private const int MaxRangeDays = 92; - private readonly IRepository _slips; - private readonly IRepository _bundles; - private readonly IRepository _invoicePayments; - private readonly IRepository _expensePayments; - private readonly IRepository _deposits; + private readonly ICashInHandRepository _cash; private readonly ICurrentBranchContext _currentBranch; private readonly BusinessDayClock _clock; private readonly string? _defaultCashAccountCode; public CashInHandService( - IRepository slips, IRepository bundles, IRepository invoicePayments, - IRepository expensePayments, IRepository deposits, - ICurrentBranchContext currentBranch, BusinessDayClock clock, IConfiguration configuration) + ICashInHandRepository cash, ICurrentBranchContext currentBranch, BusinessDayClock clock, IConfiguration configuration) { - _slips = slips; - _bundles = bundles; - _invoicePayments = invoicePayments; - _expensePayments = expensePayments; - _deposits = deposits; + _cash = cash; _currentBranch = currentBranch; _clock = clock; _defaultCashAccountCode = configuration["BankDeposit:DefaultCashAccountCode"]; @@ -105,29 +93,21 @@ public sealed class CashInHandService : ICashInHandService { DateTime? lo = TrackingStartDate is { } s ? _clock.ToUtc(s) : null; var hi = _clock.ToUtc(from); - var cashIn = await Slips(lo, hi, branch).SumAsync(s => s.CashAmount, ct) - + await Bundles(lo, hi, branch).SumAsync(b => b.CashAmount, ct) - + await InvoicePayments(lo, hi, branch).SumAsync(p => p.Amount, ct); - var cashOut = await ExpensePayments(lo, hi, branch).SumAsync(p => p.Amount, ct); - var deposited = await Deposits(TrackingStartDate, from, branch).SumAsync(d => d.Amount, ct); + var cashIn = await _cash.SumCashInAsync(lo, hi, branch, ct); + var cashOut = await _cash.SumCashOutAsync(lo, hi, branch, ct); + var deposited = await _cash.SumDepositsAsync(TrackingStartDate, from, branch, ct); opening = cashIn - cashOut - deposited; } // The window itself, bucketed by local day. var start = _clock.ToUtc(from); var end = _clock.ToUtc(to.AddDays(1)); - var inRows = new List<(DateTime At, decimal Amount)>(); - inRows.AddRange((await Slips(start, end, branch).Select(s => new { s.SlipDate, s.CashAmount }).ToListAsync(ct)) - .Select(r => (r.SlipDate, r.CashAmount))); - inRows.AddRange((await Bundles(start, end, branch).Select(b => new { b.BundleDate, b.CashAmount }).ToListAsync(ct)) - .Select(r => (r.BundleDate, r.CashAmount))); - inRows.AddRange((await InvoicePayments(start, end, branch).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct)) - .Select(r => (r.PaymentDate, r.Amount))); - var outRows = await ExpensePayments(start, end, branch).Select(p => new { p.PaymentDate, p.Amount }).ToListAsync(ct); - var depositRows = await Deposits(from, to.AddDays(1), branch).Select(d => new { d.BusinessDate, d.Amount }).ToListAsync(ct); + var inRows = await _cash.ListCashInAsync(start, end, branch, ct); + var outRows = await _cash.ListCashOutAsync(start, end, branch, ct); + var depositRows = await _cash.ListDepositsAsync(from, to.AddDays(1), branch, ct); var inByDay = inRows.GroupBy(r => _clock.LocalDay(r.At)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount)); - var outByDay = outRows.GroupBy(r => _clock.LocalDay(r.PaymentDate)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount)); + var outByDay = outRows.GroupBy(r => _clock.LocalDay(r.At)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount)); var depositByDay = depositRows.GroupBy(r => r.BusinessDate).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount)); var days = new List(to.DayNumber - from.DayNumber + 1); @@ -149,48 +129,4 @@ public sealed class CashInHandService : ICashInHandService } return days; } - - // --- Sources. lo is inclusive (null = no lower bound), hi exclusive. --- - - private IQueryable Slips(DateTime? lo, DateTime hi, int? branch) - { - var q = _slips.Query().AsNoTracking() - .Where(s => s.Status == SalesSlipStatus.Posted && s.CashAmount != 0 && s.SlipDate < hi); - if (lo is not null) q = q.Where(s => s.SlipDate >= lo); - return branch is null ? q : q.Where(s => s.Warehouse!.BranchId == branch); - } - - private IQueryable Bundles(DateTime? lo, DateTime hi, int? branch) - { - var q = _bundles.Query().AsNoTracking() - .Where(b => b.Status == BundleSaleStatus.Posted && b.CashAmount != 0 && b.BundleDate < hi); - if (lo is not null) q = q.Where(b => b.BundleDate >= lo); - return branch is null ? q : q.Where(b => b.Warehouse!.BranchId == branch); - } - - /// Cash received against Posted invoices — whatever day the invoice itself is from. - private IQueryable InvoicePayments(DateTime? lo, DateTime hi, int? branch) - { - var q = _invoicePayments.Query().AsNoTracking() - .Where(p => p.PaymentMethod == SalesPaymentMethod.Cash && p.SalesInvoice!.Status == SalesInvoiceStatus.Posted && p.PaymentDate < hi); - if (lo is not null) q = q.Where(p => p.PaymentDate >= lo); - return branch is null ? q : q.Where(p => p.SalesInvoice!.BranchId == branch); - } - - private IQueryable ExpensePayments(DateTime? lo, DateTime hi, int? branch) - { - var q = _expensePayments.Query().AsNoTracking() - .Where(p => p.PaymentMethod == ExpensePaymentMethod.Cash && p.Expense!.Status != ExpenseStatus.Cancelled && p.PaymentDate < hi); - if (lo is not null) q = q.Where(p => p.PaymentDate >= lo); - return branch is null ? q : q.Where(p => p.Expense!.BranchId == branch); - } - - /// Posted deposits by business date; inclusive (null = no bound), exclusive. - private IQueryable Deposits(DateOnly? lo, DateOnly hi, int? branch) - { - var q = _deposits.Query().AsNoTracking() - .Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate < hi); - if (lo is not null) q = q.Where(d => d.BusinessDate >= lo); - return branch is null ? q : q.Where(d => d.BranchId == branch); - } } diff --git a/Backend/ERPCore/Services/CategoryService.cs b/Backend/ERPCore/Services/CategoryService.cs index 0026a28..70c8b01 100644 --- a/Backend/ERPCore/Services/CategoryService.cs +++ b/Backend/ERPCore/Services/CategoryService.cs @@ -7,28 +7,24 @@ using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in ICategoryRepository namespace ERPCore.Services; /// /// Category + subcategory master service (FR-MD-04). The hierarchy is exactly two levels: /// categories no longer self-nest, so there is no cycle to detect and no tree to build -/// (docs/11-BACKEND-PHASE1.md §2.3). +/// (docs/11-BACKEND-PHASE1.md §2.3). Queries go through ; writes run +/// inside like . /// public sealed class CategoryService : ICategoryService { - private readonly IRepository _categories; - private readonly IRepository _subCategories; + private readonly ICategoryRepository _categories; private readonly IUnitOfWork _uow; - public CategoryService( - IRepository categories, - IRepository subCategories, - IUnitOfWork uow) + public CategoryService(ICategoryRepository categories, IUnitOfWork uow) { _categories = categories; - _subCategories = subCategories; _uow = uow; } @@ -37,45 +33,34 @@ public sealed class CategoryService : ICategoryService public async Task> ListAsync( PageQuery query, EntityStatus? status, CancellationToken ct = default) { - var q = _categories.Query().AsNoTracking(); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%")); - } - if (status is not null) q = q.Where(c => c.Status == status); - - var total = await q.CountAsync(ct); - var rows = await q.OrderBy(c => c.Name) - .Skip(query.Skip).Take(query.PageSize) - .Select(c => new CategoryDto(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt)) - .ToListAsync(ct); - - return PagedResponse.Create(rows, query.Page, query.PageSize, total); + var (rows, total) = await _categories.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct); + return PagedResponse.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); } public async Task?> GetAsync(int categoryId, CancellationToken ct = default) { - var category = await _categories.Query().AsNoTracking() - .FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct); + var category = await _categories.GetForReadAsync(categoryId, ct); return category is null ? null : new ETagged(Map(category), category.RowVersion); } public async Task> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default) { var name = request.Name.Trim(); - if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct)) - throw new ConflictException($"A category named '{name}' already exists."); - var category = new Category + var category = await _uow.ExecuteInTransactionAsync(async token => { - Name = name, - Status = EntityStatus.Active, - CreatedAt = DateTime.UtcNow - }; + if (await _categories.NameExistsAsync(name, ct: token)) + throw new ConflictException($"A category named '{name}' already exists."); - await _categories.AddAsync(category, ct); - await _uow.SaveChangesAsync(ct); + var entity = new Category + { + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + await _categories.AddAsync(entity, token); + return entity; + }, ct); return new ETagged(Map(category), category.RowVersion); } @@ -90,14 +75,17 @@ public sealed class CategoryService : ICategoryService throw new DomainException(ErrorCodes.ConcurrencyConflict, "The category was modified by another request.", 412); var name = request.Name.Trim(); - if (!string.Equals(category.Name, name, StringComparison.Ordinal) - && await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower() && c.CategoryId != categoryId, ct)) - throw new ConflictException($"A category named '{name}' already exists."); - category.Name = name; - category.UpdatedAt = DateTime.UtcNow; + await InTransactionGuardingConcurrencyAsync("category", async token => + { + if (!string.Equals(category.Name, name, StringComparison.Ordinal) + && await _categories.NameExistsAsync(name, categoryId, token)) + throw new ConflictException($"A category named '{name}' already exists."); + + category.Name = name; + category.UpdatedAt = DateTime.UtcNow; + }, ct); - await SaveGuardingConcurrencyAsync("category", ct); return new ETagged(Map(category), category.RowVersion); } @@ -106,9 +94,12 @@ public sealed class CategoryService : ICategoryService var category = await _categories.GetByIdAsync(categoryId, ct) ?? throw new NotFoundException($"Category {categoryId} was not found."); - category.Status = status; - category.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + category.Status = status; + category.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); } // Subcategories ------------------------------------------------------------ @@ -116,59 +107,45 @@ public sealed class CategoryService : ICategoryService public async Task> ListSubCategoriesAsync( int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default) { - if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct)) + if (!await _categories.ExistsAsync(categoryId, ct)) throw new NotFoundException($"Category {categoryId} was not found."); - var q = _subCategories.Query().AsNoTracking().Where(s => s.CategoryId == categoryId); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(s => EF.Functions.Like(s.Name, $"%{term}%")); - } - if (status is not null) q = q.Where(s => s.Status == status); - - var total = await q.CountAsync(ct); - var rows = await q.OrderBy(s => s.Name) - .Skip(query.Skip).Take(query.PageSize) - .Select(s => new SubCategoryDto( - s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt)) - .ToListAsync(ct); - - return PagedResponse.Create(rows, query.Page, query.PageSize, total); + var (rows, total) = await _categories.ListSubCategoriesPagedAsync(categoryId, query.Q, status, query.Skip, query.PageSize, ct); + return PagedResponse.Create(rows.Select(MapSub).ToList(), query.Page, query.PageSize, total); } public async Task?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default) { - var sub = await _subCategories.Query().AsNoTracking() - .FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct); + var sub = await _categories.GetSubCategoryForReadAsync(subCategoryId, ct); return sub is null ? null : new ETagged(MapSub(sub), sub.RowVersion); } public async Task> CreateSubCategoryAsync( int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default) { - var category = await _categories.Query().AsNoTracking() - .FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct) + var category = await _categories.GetForReadAsync(categoryId, ct) ?? throw new NotFoundException($"Category {categoryId} was not found."); if (category.Status != EntityStatus.Active) throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} is inactive.", 422); var name = request.Name.Trim(); - if (await _subCategories.Query().AnyAsync( - s => s.CategoryId == categoryId && s.Name.ToLower() == name.ToLower(), ct)) - throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}."); - var sub = new SubCategory + var sub = await _uow.ExecuteInTransactionAsync(async token => { - CategoryId = categoryId, - Name = name, - Status = EntityStatus.Active, - CreatedAt = DateTime.UtcNow - }; + if (await _categories.SubCategoryNameExistsAsync(categoryId, name, ct: token)) + throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}."); - await _subCategories.AddAsync(sub, ct); - await _uow.SaveChangesAsync(ct); + var entity = new SubCategory + { + CategoryId = categoryId, + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + await _categories.AddSubCategoryAsync(entity, token); + return entity; + }, ct); return new ETagged(MapSub(sub), sub.RowVersion); } @@ -176,44 +153,50 @@ public sealed class CategoryService : ICategoryService public async Task> UpdateSubCategoryAsync( int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default) { - var sub = await _subCategories.GetByIdAsync(subCategoryId, ct) + var sub = await _categories.GetSubCategoryForUpdateAsync(subCategoryId, ct) ?? throw new NotFoundException($"Subcategory {subCategoryId} was not found."); if (sub.RowVersion != expectedRowVersion) throw new DomainException(ErrorCodes.ConcurrencyConflict, "The subcategory was modified by another request.", 412); var name = request.Name.Trim(); - if (!string.Equals(sub.Name, name, StringComparison.Ordinal) - && await _subCategories.Query().AnyAsync( - s => s.CategoryId == sub.CategoryId && s.Name.ToLower() == name.ToLower() && s.SubCategoryId != subCategoryId, ct)) - throw new ConflictException($"A subcategory named '{name}' already exists under category {sub.CategoryId}."); - // Name only — reparenting is not offered, since it would silently invalidate the - // category of every item pointing at this subcategory. - sub.Name = name; - sub.UpdatedAt = DateTime.UtcNow; + await InTransactionGuardingConcurrencyAsync("subcategory", async token => + { + if (!string.Equals(sub.Name, name, StringComparison.Ordinal) + && await _categories.SubCategoryNameExistsAsync(sub.CategoryId, name, subCategoryId, token)) + throw new ConflictException($"A subcategory named '{name}' already exists under category {sub.CategoryId}."); + + // Name only — reparenting is not offered, since it would silently invalidate the + // category of every item pointing at this subcategory. + sub.Name = name; + sub.UpdatedAt = DateTime.UtcNow; + }, ct); - await SaveGuardingConcurrencyAsync("subcategory", ct); return new ETagged(MapSub(sub), sub.RowVersion); } public async Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default) { - var sub = await _subCategories.GetByIdAsync(subCategoryId, ct) + var sub = await _categories.GetSubCategoryForUpdateAsync(subCategoryId, ct) ?? throw new NotFoundException($"Subcategory {subCategoryId} was not found."); - sub.Status = status; - sub.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + sub.Status = status; + sub.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); } // --------------------------------------------------------------------------- - private async Task SaveGuardingConcurrencyAsync(string label, CancellationToken ct) + /// Runs in a transaction; a lost optimistic-concurrency race becomes a 412. + private async Task InTransactionGuardingConcurrencyAsync(string label, Func change, CancellationToken ct) { try { - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(change, ct); } catch (DbUpdateConcurrencyException) { diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs index 7ff851d..e23ee8f 100644 --- a/Backend/ERPCore/Services/CountService.cs +++ b/Backend/ERPCore/Services/CountService.cs @@ -10,7 +10,6 @@ using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; using ERPCore.System.Time; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -21,94 +20,62 @@ namespace ERPCore.Services; /// C.7). That adjustment follows the same approval rules as any other /// (docs/DUAL_APPROVAL_PLAN.md §8, decision 3) — a count is not a way around approval — and /// moves stock when it is approved, or at once when approvals are switched off. +/// Queries go through ; writes run inside +/// like . /// public sealed class CountService : ICountService { private const string VarianceReasonCode = "VAR"; - private readonly IRepository _counts; - private readonly IRepository _warehouses; - private readonly IRepository _items; - private readonly IRepository _reasonCodes; + private readonly ICountRepository _counts; private readonly IFifoCostingService _fifo; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; private readonly IBranchConfigService _branchConfig; private readonly IUnitOfWork _uow; - private readonly IRepository _stockLayers; - private readonly IRepository _batches; - private readonly IRepository _serials; private readonly ISerialTrackingService _serialTracking; private readonly AdjustmentService _adjustments; public CountService( - IRepository counts, IRepository warehouses, - IRepository items, IRepository reasonCodes, IFifoCostingService fifo, - INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow, - IRepository stockLayers, IRepository batches, - IRepository serials, ISerialTrackingService serialTracking, AdjustmentService adjustments) + ICountRepository counts, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, + IBranchConfigService branchConfig, IUnitOfWork uow, ISerialTrackingService serialTracking, AdjustmentService adjustments) { - _serialTracking = serialTracking; _counts = counts; - _warehouses = warehouses; - _items = items; - _reasonCodes = reasonCodes; _fifo = fifo; _numbers = numbers; _currentUser = currentUser; _branchConfig = branchConfig; _uow = uow; - _stockLayers = stockLayers; - _batches = batches; - _serials = serials; + _serialTracking = serialTracking; _adjustments = adjustments; } public async Task> ListAsync( PageQuery query, CountStatus? status, int? warehouseId, int? branchId, CancellationToken ct = default) { - var q = _counts.Query().AsNoTracking(); - - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(c => EF.Functions.Like(c.DocNo, $"%{term}%")); - } - if (status is not null) q = q.Where(c => c.Status == status); - if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId); - // Warehouses are shared across branches — the count carries its own owning BranchId. - if (branchId is not null) q = q.Where(c => c.BranchId == branchId); - - var total = await q.CountAsync(ct); - var rows = await q.OrderByDescending(c => c.CountId) - .Skip(query.Skip).Take(query.PageSize) - .Select(c => new CountSummaryDto( - c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status, - c.CreatedBy, c.CreatedAt, c.Lines.Count)) - .ToListAsync(ct); - - return PagedResponse.Create(rows, query.Page, query.PageSize, total); + var (rows, total) = await _counts.ListPagedAsync(query.Q, status, warehouseId, branchId, query.Skip, query.PageSize, ct); + var items = rows.Select(r => new CountSummaryDto( + r.Count.CountId, r.Count.DocNo, r.Count.WarehouseId, r.Count.BranchId, r.Count.CountType, r.Count.Status, + r.Count.CreatedBy, r.Count.CreatedAt, r.LineCount)).ToList(); + return PagedResponse.Create(items, query.Page, query.PageSize, total); } public async Task GetAsync(int countId, CancellationToken ct = default) { - 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); + var count = await _counts.GetForReadAsync(countId, ct); return count is null ? null : Map(count); } public async Task CreateAsync(CreateCountRequest request, CancellationToken ct = default) { - if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct)) + if (!await _counts.WarehouseExistsAsync(request.WarehouseId, ct)) throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422); var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); var itemIds = request.ItemIds.Distinct().ToList(); foreach (var id in itemIds) - if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct)) + if (!await _counts.ItemExistsAsync(id, ct)) throw new DomainException(ErrorCodes.Validation, $"Item {id} does not exist.", 422); // Snapshot system quantities now (immutable once opened). Batch/serial-tracked items @@ -116,10 +83,10 @@ public sealed class CountService : ICountService var lines = new List(); foreach (var id in itemIds) { - var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == id, ct); + var item = await _counts.GetItemAsync(id, ct); if (item.TrackingMode == TrackingMode.Batch) { - var batches = await _batches.Query().AsNoTracking().Where(b => b.ItemId == id).ToListAsync(ct); + var batches = await _counts.ListBatchesAsync(id, ct); if (batches.Count == 0) { lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = 0 }); @@ -127,15 +94,11 @@ public sealed class CountService : ICountService } 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; + var systemQty = await _counts.SumBatchQtyAsync(id, request.WarehouseId, batch.BatchId, ct); lines.Add(new StockCountLine { ItemId = id, BatchId = batch.BatchId, SystemQty = systemQty }); } // Stock that reached the item without a batch (e.g. an adjustment) is counted on its own line. - var unbatched = await _stockLayers.Query().AsNoTracking() - .Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == null) - .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + var unbatched = await _counts.SumBatchQtyAsync(id, request.WarehouseId, null, ct); if (unbatched != 0) lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = unbatched }); } @@ -143,19 +106,13 @@ public sealed class CountService : ICountService { // Serials that should be at this warehouse (one line each, counted 0 or 1), plus // one aggregate line for any pre-tracking serial-less stock still here (D6). - var serials = await _serials.Query().AsNoTracking() - .Where(s => s.ItemId == id && s.WarehouseId == request.WarehouseId - && (s.Status == SerialStatus.InStock || s.Status == SerialStatus.Damaged)) - .OrderBy(s => s.SerialNo).ToListAsync(ct); + var serials = await _counts.ListCountableSerialsAsync(id, request.WarehouseId, 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); + var open = await _counts.HasOpenSerialLayerAsync(id, request.WarehouseId, serial.SerialId, ct); lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 }); } - var untracked = await _stockLayers.Query().AsNoTracking() - .Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == null) - .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + var untracked = await _counts.SumUnserializedQtyAsync(id, request.WarehouseId, ct); if (untracked != 0 || serials.Count == 0) lines.Add(new StockCountLine { ItemId = id, SystemQty = untracked }); } @@ -189,35 +146,33 @@ public sealed class CountService : ICountService public async Task EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default) { - 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) + var count = await _counts.GetForUpdateAsync(countId, ct) ?? throw new NotFoundException($"Count {countId} was not found."); if (count.Status == CountStatus.Posted) throw new ConflictException($"Count {countId} is already posted."); - foreach (var input in request.Lines) + await _uow.ExecuteInTransactionAsync(_ => { - var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId) - ?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422); - if (line.SerialId is not null && input.CountedQty is not (0m or 1m)) - throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is one serial — count it as 0 or 1.", 422); - line.CountedQty = input.CountedQty; - line.Variance = input.CountedQty - line.SystemQty; - } + foreach (var input in request.Lines) + { + var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId) + ?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422); + if (line.SerialId is not null && input.CountedQty is not (0m or 1m)) + throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is one serial — count it as 0 or 1.", 422); + line.CountedQty = input.CountedQty; + line.Variance = input.CountedQty - line.SystemQty; + } + + count.Status = CountStatus.Counted; + return Task.CompletedTask; + }, ct); - count.Status = CountStatus.Counted; - await _uow.SaveChangesAsync(ct); return Map(count); } public async Task PostAsync(int countId, CancellationToken ct = default) { - 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) + var count = await _counts.GetForUpdateAsync(countId, ct) ?? throw new NotFoundException($"Count {countId} was not found."); if (count.Status == CountStatus.Posted) throw new ConflictException($"Count {countId} is already posted."); @@ -230,13 +185,15 @@ public sealed class CountService : ICountService // No variances → just close the count, no adjustment. if (varianceLines.Count == 0) { - count.Status = CountStatus.Posted; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + count.Status = CountStatus.Posted; + return Task.CompletedTask; + }, ct); return new CountPostResultDto(count.CountId, count.Status, null, null, Array.Empty()); } - var reason = await _reasonCodes.Query().AsNoTracking() - .FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct) + var reason = await _counts.GetAdjustmentReasonAsync(VarianceReasonCode, ct) ?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422); // A serial counted missing is written off (Scrapped); one found is brought back InStock — @@ -257,6 +214,8 @@ public sealed class CountService : ICountService }).ToList() }; + // AdjustmentService runs its own transaction; closing the count is done inside it, so the + // count is Posted exactly when its adjustment is saved. var (adjustment, refs) = await _adjustments.SubmitAsync(request, _ => { count.Status = CountStatus.Posted; diff --git a/Backend/ERPCore/Services/CustomerService.cs b/Backend/ERPCore/Services/CustomerService.cs index e1a376f..13f4ebd 100644 --- a/Backend/ERPCore/Services/CustomerService.cs +++ b/Backend/ERPCore/Services/CustomerService.cs @@ -7,114 +7,53 @@ using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in ICustomerRepository namespace ERPCore.Services; +/// +/// Customer master service. Queries go through ; writes run inside +/// like . +/// public sealed class CustomerService : ICustomerService { - private readonly IRepository _customers; - private readonly IRepository _warehouses; + private readonly ICustomerRepository _customers; private readonly IBranchConfigService _branchConfig; - private readonly IRepository _salesConfig; - private readonly IRepository _invoices; - private readonly IRepository _invoicePayments; private readonly IUnitOfWork _uow; /// Code of the system "Cash Customer" row (see ). private const string CashCustomerCode = "CUST-CASH"; - public CustomerService( - IRepository customers, IRepository warehouses, IBranchConfigService branchConfig, - IRepository salesConfig, IRepository invoices, - IRepository invoicePayments, IUnitOfWork uow) + public CustomerService(ICustomerRepository customers, IBranchConfigService branchConfig, IUnitOfWork uow) { _customers = customers; - _warehouses = warehouses; _branchConfig = branchConfig; - _salesConfig = salesConfig; - _invoices = invoices; - _invoicePayments = invoicePayments; _uow = uow; } public async Task> ListAsync( PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default) { - var q = _customers.Query().AsNoTracking(); - var cashCustomerId = await _salesConfig.Query().AsNoTracking() - .Where(c => c.ConfigId == SalesConfig.SingletonId) - .Select(c => c.CashCustomerId) - .FirstOrDefaultAsync(ct); - // The generic cash customer is a system default for walk-in / one-time buyers, not a registered - // customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names). - if (!includeCashCustomer) - q = q.Where(c => c.CustomerCode != CashCustomerCode && (cashCustomerId == null || c.CustomerId != cashCustomerId)); - // Branch-wise, same as the Sales / GRN lists: a customer carries the BranchId it was created - // under. The cash customer is shared by every branch, so it is never filtered out here. - if (branchId is not null) - q = q.Where(c => c.BranchId == branchId - || c.CustomerCode == CashCustomerCode - || (cashCustomerId != null && c.CustomerId == cashCustomerId)); - if (!string.IsNullOrWhiteSpace(query.Q)) + var criteria = new CustomerListCriteria(query.Q, status, customerType, includeCashCustomer, branchId, CashCustomerCode); + var (rows, total) = await _customers.ListPagedAsync(criteria, query.Skip, query.PageSize, ct); + + // Each customer's receivable position — the unpaid balance and count of their Posted sales + // invoices, and their latest invoice payment — for just the customers on this page. + var receivables = await _customers.GetReceivablesAsync(rows.Select(r => r.CustomerId).ToList(), ct); + var items = rows.Select(c => { - var term = query.Q.Trim(); - q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%") - || EF.Functions.Like(c.CustomerCode, $"%{term}%") - || (c.DisplayName != null && EF.Functions.Like(c.DisplayName, $"%{term}%"))); - } - if (status is not null) q = q.Where(c => c.Status == status); - if (customerType is not null) q = q.Where(c => c.CustomerType == customerType); - - var total = await q.CountAsync(ct); - var rows = await q.OrderBy(c => c.Name) - .Skip(query.Skip).Take(query.PageSize) - .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.BranchId, c.CreatedAt, c.UpdatedAt)) - .ToListAsync(ct); - - return PagedResponse.Create(await WithReceivablesAsync(rows, ct), query.Page, query.PageSize, total); - } - - /// - /// Adds each customer's receivable position — the unpaid balance and count of their Posted sales - /// invoices, and their latest invoice payment — for just the customers on this page. - /// - private async Task> WithReceivablesAsync(List rows, CancellationToken ct) - { - if (rows.Count == 0) return rows; - var ids = rows.Select(r => r.CustomerId).ToList(); - - var dues = await _invoices.Query().AsNoTracking() - .Where(i => ids.Contains(i.CustomerId) && i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0) - .GroupBy(i => i.CustomerId) - .Select(g => new { CustomerId = g.Key, Amount = g.Sum(i => i.BalanceAmount), Count = g.Count() }) - .ToDictionaryAsync(x => x.CustomerId, ct); - - var lastPaid = await _invoicePayments.Query().AsNoTracking() - .Where(p => ids.Contains(p.SalesInvoice!.CustomerId)) - .GroupBy(p => p.SalesInvoice!.CustomerId) - .Select(g => new { CustomerId = g.Key, Last = g.Max(p => p.PaymentDate) }) - .ToDictionaryAsync(x => x.CustomerId, x => x.Last, ct); - - return rows.Select(r => - { - var due = dues.GetValueOrDefault(r.CustomerId); - return r with - { - OutstandingAmount = due?.Amount ?? 0m, - OutstandingInvoiceCount = due?.Count ?? 0, - LastPaymentDate = lastPaid.TryGetValue(r.CustomerId, out var last) ? last : null - }; + var dto = Map(c); + return receivables.TryGetValue(c.CustomerId, out var r) + ? dto with { OutstandingAmount = r.OutstandingAmount, OutstandingInvoiceCount = r.OutstandingInvoiceCount, LastPaymentDate = r.LastPaymentDate } + : dto; }).ToList(); + + return PagedResponse.Create(items, query.Page, query.PageSize, total); } public async Task?> GetAsync(int customerId, CancellationToken ct = default) { - var customer = await _customers.Query().AsNoTracking() - .FirstOrDefaultAsync(c => c.CustomerId == customerId, ct); + var customer = await _customers.GetForReadAsync(customerId, ct); return customer is null ? null : new ETagged(Map(customer), customer.RowVersion); } @@ -122,48 +61,47 @@ public sealed class CustomerService : ICustomerService { var code = request.CustomerCode.Trim(); var name = request.Name.Trim(); - - if (await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower(), ct)) - throw new ConflictException($"A customer code '{code}' already exists."); - - if (!string.IsNullOrWhiteSpace(request.Email)) - { - var email = request.Email.Trim(); - if (await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == email.ToLower(), ct)) - throw new ConflictException($"A customer with email '{email}' already exists."); - } - - if (request.DefaultWarehouseId is not null) - { - var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct); - if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found."); - } - var fields = await GetFieldSwitchesAsync(ct); var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); - var customer = new Customer - { - CustomerCode = code, - CustomerType = request.CustomerType, - Name = name, - DisplayName = Normalize(request.DisplayName), - Phone = Gate(fields.Phone, request.Phone, null, "phone numbers"), - Email = Gate(fields.Email, request.Email, null, "email addresses"), - AddressLine1 = Gate(fields.Address, request.AddressLine1, null, "addresses"), - AddressLine2 = Gate(fields.Address, request.AddressLine2, null, "addresses"), - City = Gate(fields.City, request.City, null, "cities"), - Country = Gate(fields.Country, request.Country, null, "countries"), - TaxRegistrationNo = Normalize(request.TaxRegistrationNo), - CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, 0m, "credit limits"), - CreditDays = Gate(fields.CreditDays, request.CreditDays, 0, "credit days"), - DefaultWarehouseId = request.DefaultWarehouseId, - Status = EntityStatus.Active, - BranchId = branchId, - CreatedAt = DateTime.UtcNow - }; - await _customers.AddAsync(customer, ct); - await _uow.SaveChangesAsync(ct); + var customer = await _uow.ExecuteInTransactionAsync(async token => + { + if (await _customers.CodeExistsAsync(code, ct: token)) + throw new ConflictException($"A customer code '{code}' already exists."); + + if (!string.IsNullOrWhiteSpace(request.Email)) + { + var email = request.Email.Trim(); + if (await _customers.EmailExistsAsync(email, ct: token)) + throw new ConflictException($"A customer with email '{email}' already exists."); + } + + if (request.DefaultWarehouseId is { } warehouseId && !await _customers.WarehouseExistsAsync(warehouseId, token)) + throw new NotFoundException($"Warehouse {warehouseId} was not found."); + + var entity = new Customer + { + CustomerCode = code, + CustomerType = request.CustomerType, + Name = name, + DisplayName = Normalize(request.DisplayName), + Phone = Gate(fields.Phone, request.Phone, null, "phone numbers"), + Email = Gate(fields.Email, request.Email, null, "email addresses"), + AddressLine1 = Gate(fields.Address, request.AddressLine1, null, "addresses"), + AddressLine2 = Gate(fields.Address, request.AddressLine2, null, "addresses"), + City = Gate(fields.City, request.City, null, "cities"), + Country = Gate(fields.Country, request.Country, null, "countries"), + TaxRegistrationNo = Normalize(request.TaxRegistrationNo), + CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, 0m, "credit limits"), + CreditDays = Gate(fields.CreditDays, request.CreditDays, 0, "credit days"), + DefaultWarehouseId = request.DefaultWarehouseId, + Status = EntityStatus.Active, + BranchId = branchId, + CreatedAt = DateTime.UtcNow + }; + await _customers.AddAsync(entity, token); + return entity; + }, ct); return new ETagged(Map(customer), customer.RowVersion); } @@ -178,44 +116,43 @@ public sealed class CustomerService : ICustomerService var code = request.CustomerCode.Trim(); var name = request.Name.Trim(); - - if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal) - && await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower() && c.CustomerId != customerId, ct)) - throw new ConflictException($"A customer code '{code}' already exists."); - - if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrWhiteSpace(request.Email) - && await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == request.Email!.Trim().ToLower() && c.CustomerId != customerId, ct)) - throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists."); - - if (request.DefaultWarehouseId is not null) - { - var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct); - if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found."); - } - - customer.CustomerCode = code; - customer.CustomerType = request.CustomerType; - customer.Name = name; - customer.DisplayName = Normalize(request.DisplayName); // A field switched off in Sales Configuration keeps whatever is stored; only a *different* value is refused. var fields = await GetFieldSwitchesAsync(ct); - customer.Phone = Gate(fields.Phone, request.Phone, customer.Phone, "phone numbers"); - customer.Email = Gate(fields.Email, request.Email, customer.Email, "email addresses"); - customer.AddressLine1 = Gate(fields.Address, request.AddressLine1, customer.AddressLine1, "addresses"); - customer.AddressLine2 = Gate(fields.Address, request.AddressLine2, customer.AddressLine2, "addresses"); - customer.City = Gate(fields.City, request.City, customer.City, "cities"); - customer.Country = Gate(fields.Country, request.Country, customer.Country, "countries"); - customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo); - customer.CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, customer.CreditLimit, "credit limits"); - customer.CreditDays = Gate(fields.CreditDays, request.CreditDays, customer.CreditDays, "credit days"); - customer.DefaultWarehouseId = request.DefaultWarehouseId; - customer.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); - customer.UpdatedAt = DateTime.UtcNow; + var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); try { - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(async token => + { + if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal) + && await _customers.CodeExistsAsync(code, customerId, token)) + throw new ConflictException($"A customer code '{code}' already exists."); + + if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(request.Email) + && await _customers.EmailExistsAsync(request.Email.Trim(), customerId, token)) + throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists."); + + if (request.DefaultWarehouseId is { } warehouseId && !await _customers.WarehouseExistsAsync(warehouseId, token)) + throw new NotFoundException($"Warehouse {warehouseId} was not found."); + + customer.CustomerCode = code; + customer.CustomerType = request.CustomerType; + customer.Name = name; + customer.DisplayName = Normalize(request.DisplayName); + customer.Phone = Gate(fields.Phone, request.Phone, customer.Phone, "phone numbers"); + customer.Email = Gate(fields.Email, request.Email, customer.Email, "email addresses"); + customer.AddressLine1 = Gate(fields.Address, request.AddressLine1, customer.AddressLine1, "addresses"); + customer.AddressLine2 = Gate(fields.Address, request.AddressLine2, customer.AddressLine2, "addresses"); + customer.City = Gate(fields.City, request.City, customer.City, "cities"); + customer.Country = Gate(fields.Country, request.Country, customer.Country, "countries"); + customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo); + customer.CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, customer.CreditLimit, "credit limits"); + customer.CreditDays = Gate(fields.CreditDays, request.CreditDays, customer.CreditDays, "credit days"); + customer.DefaultWarehouseId = request.DefaultWarehouseId; + customer.BranchId = branchId; + customer.UpdatedAt = DateTime.UtcNow; + }, ct); } catch (DbUpdateConcurrencyException) { @@ -230,9 +167,12 @@ public sealed class CustomerService : ICustomerService var customer = await _customers.GetByIdAsync(customerId, ct) ?? throw new NotFoundException($"Customer {customerId} was not found."); - customer.Status = status; - customer.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + customer.Status = status; + customer.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); } private static CustomerDto Map(Customer c) => new( @@ -243,16 +183,9 @@ public sealed class CustomerService : ICustomerService private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); /// Which optional customer fields Sales Configuration has switched on (all on when the row is missing). - private sealed record FieldSwitches(bool Phone, bool Email, bool Address, bool City, bool Country, bool CreditLimit, bool CreditDays); - - private async Task GetFieldSwitchesAsync(CancellationToken ct) => - await _salesConfig.Query().AsNoTracking() - .Where(c => c.ConfigId == SalesConfig.SingletonId) - .Select(c => new FieldSwitches( - c.CustomerPhoneEnabled, c.CustomerEmailEnabled, c.CustomerAddressEnabled, c.CustomerCityEnabled, - c.CustomerCountryEnabled, c.CustomerCreditLimitEnabled, c.CustomerCreditDaysEnabled)) - .FirstOrDefaultAsync(ct) - ?? new FieldSwitches(true, true, true, true, true, true, true); + private async Task GetFieldSwitchesAsync(CancellationToken ct) => + await _customers.GetFieldSwitchesAsync(ct) + ?? new CustomerFieldSwitches(true, true, true, true, true, true, true); /// On: the (trimmed) requested value. Off: the stored value is kept; a different value is a 422 CONFIG_DISABLED /// (the UI hides the field, so only a stale or hand-built request gets here). diff --git a/Backend/ERPCore/Services/DayEndReportService.cs b/Backend/ERPCore/Services/DayEndReportService.cs index 89a8e8c..10a7e97 100644 --- a/Backend/ERPCore/Services/DayEndReportService.cs +++ b/Backend/ERPCore/Services/DayEndReportService.cs @@ -1,5 +1,4 @@ using ERPCore.Domain; -using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; using ERPCore.Dtos.Accounts; using ERPCore.Dtos.Reports; @@ -7,49 +6,32 @@ using ERPCore.Infra.Auth; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; /// /// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Sales documents are stamped in /// UTC, so each business day is the calendar day converted to a UTC -/// [start, end) window. The database does the filtering and returns slim projections; the -/// rows are bucketed into local days here, since MariaDB can't group by a time-zone-shifted date -/// portably and a ≤ 92-day window of slim rows is small. Cost of goods sold is the stock ledger's -/// Out value per sale document — the same source posts COGS from. -/// The cash figures (net cash, deposits, cash in hand) come from the Bank Deposit module's +/// [start, end) window. The database does the filtering and returns slim projections +/// (); the rows are bucketed into local days here, since MariaDB +/// can't group by a time-zone-shifted date portably and a ≤ 92-day window of slim rows is small. Cost of +/// goods sold is the stock ledger's Out value per sale document — the same source +/// posts COGS from. The cash figures (net cash, deposits, cash in hand) come from the Bank Deposit module's /// (docs/BANK_DEPOSIT_MODULE_PLAN.md §9); this report owns none of them. /// public sealed class DayEndReportService : IDayEndReportService { private const int MaxRangeDays = 92; - private const int IdChunk = 1000; - private readonly IRepository _slips; - private readonly IRepository _bundles; - private readonly IRepository _invoices; - private readonly IRepository _invoicePayments; - private readonly IRepository _ledger; - private readonly IRepository _expenses; - private readonly IRepository _deposits; + private readonly IDayEndReportRepository _report; private readonly ICashInHandService _cash; private readonly ICurrentBranchContext _currentBranch; private readonly BusinessDayClock _clock; public DayEndReportService( - IRepository slips, IRepository bundles, IRepository invoices, - IRepository invoicePayments, IRepository ledger, - IRepository expenses, IRepository deposits, ICashInHandService cash, - ICurrentBranchContext currentBranch, BusinessDayClock clock) + IDayEndReportRepository report, ICashInHandService cash, ICurrentBranchContext currentBranch, BusinessDayClock clock) { - _slips = slips; - _bundles = bundles; - _invoices = invoices; - _invoicePayments = invoicePayments; - _ledger = ledger; - _expenses = expenses; - _deposits = deposits; + _report = report; _cash = cash; _currentBranch = currentBranch; _clock = clock; @@ -67,20 +49,14 @@ public sealed class DayEndReportService : IDayEndReportService s.DocType, s.DocId, s.DocNo, s.At, s.Customer, s.Total, s.Paid, s.Total - s.Paid, s.PaidBy, s.Cost, s.Total - s.Cost)) .ToList(); - var expenses = await PostedExpenses(date, date, branch) - .OrderBy(e => e.ExpenseId) - .Select(e => new DayEndReportExpenseDto( - e.ExpenseId, e.DocNo, e.Category!.Name, e.Title!.Name, e.Payee, e.Amount, e.PaidAmount)) - .ToListAsync(ct); + var expenses = data.Expenses + .Select(e => new DayEndReportExpenseDto(e.ExpenseId, e.DocNo, e.CategoryName, e.TitleName, e.Payee, e.Amount, e.PaidAmount)) + .ToList(); - var depositsQuery = _deposits.Query().AsNoTracking() - .Where(d => d.Status == BankDepositStatus.Posted && d.BusinessDate == date); - if (branch is not null) depositsQuery = depositsQuery.Where(d => d.BranchId == branch); - var deposits = await depositsQuery - .OrderBy(d => d.BankDepositId) + var deposits = (await _report.ListPostedDepositsAsync(date, branch, ct)) .Select(d => new DayEndReportDepositDto( d.BankDepositId, d.DocNo, d.ToBankAccountName, d.ToBankName, d.ToAccountNumber, d.DepositSlipNo, d.DepositDate, d.Amount)) - .ToListAsync(ct); + .ToList(); return new DayEndReportDto(data.Days[0], sales, expenses, deposits, branch, _clock.Zone.Id); } @@ -105,52 +81,23 @@ public sealed class DayEndReportService : IDayEndReportService string DocType, int DocId, string DocNo, DateTime At, DateOnly Day, string Customer, decimal Total, decimal Paid, decimal Cash, decimal Card, decimal Other, string PaidBy, decimal Cost); - private sealed record Loaded(List Days, List Sales); + private sealed record Loaded(List Days, List Sales, IReadOnlyList Expenses); private async Task LoadAsync(DateOnly from, DateOnly to, int? branch, CancellationToken ct) { var start = _clock.ToUtc(from); var end = _clock.ToUtc(to.AddDays(1)); - var slipsQuery = _slips.Query().AsNoTracking() - .Where(s => s.Status == SalesSlipStatus.Posted && s.SlipDate >= start && s.SlipDate < end); - if (branch is not null) slipsQuery = slipsQuery.Where(s => s.Warehouse!.BranchId == branch); - var slips = await slipsQuery - .Select(s => new { s.SalesSlipId, s.SlipNo, s.SlipDate, s.CustomerSnapshotName, s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount }) - .ToListAsync(ct); + var slips = await _report.ListPostedSlipsAsync(start, end, branch, ct); + var bundles = await _report.ListPostedBundlesAsync(start, end, branch, ct); + var invoices = await _report.ListPostedInvoicesAsync(start, end, branch, ct); + var payments = await _report.ListInvoicePaymentsAsync(start, end, branch, ct); - var bundlesQuery = _bundles.Query().AsNoTracking() - .Where(b => b.Status == BundleSaleStatus.Posted && b.BundleDate >= start && b.BundleDate < end); - if (branch is not null) bundlesQuery = bundlesQuery.Where(b => b.Warehouse!.BranchId == branch); - var bundles = await bundlesQuery - .Select(b => new { b.BundleSaleId, b.BundleNo, b.BundleDate, b.CustomerSnapshotName, b.GrandTotal, b.CashAmount, b.CardAmount }) - .ToListAsync(ct); + var slipCost = await _report.GetCostByDocAsync(DocumentTypes.SalesSlip, slips.Select(s => s.SalesSlipId), ct); + var bundleCost = await _report.GetCostByDocAsync(DocumentTypes.BundleSale, bundles.Select(b => b.BundleSaleId), ct); + var invoiceCost = await _report.GetCostByDocAsync(DocumentTypes.SalesInvoice, invoices.Select(i => i.SalesInvoiceId), ct); - var invoicesQuery = _invoices.Query().AsNoTracking() - .Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= start && i.InvoiceDate < end); - if (branch is not null) invoicesQuery = invoicesQuery.Where(i => i.BranchId == branch); - var invoices = await invoicesQuery - .Select(i => new { i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, i.CustomerSnapshotName, i.NetPayable, i.PaidAmount }) - .ToListAsync(ct); - - // Every payment that matters to the window: those against its invoices (the method split of - // Paid), and those received inside it against earlier invoices (collections / net cash). - var paymentsQuery = _invoicePayments.Query().AsNoTracking() - .Where(p => p.SalesInvoice!.Status == SalesInvoiceStatus.Posted - && ((p.SalesInvoice.InvoiceDate >= start && p.SalesInvoice.InvoiceDate < end) - || (p.PaymentDate >= start && p.PaymentDate < end))); - if (branch is not null) paymentsQuery = paymentsQuery.Where(p => p.SalesInvoice!.BranchId == branch); - var payments = await paymentsQuery - .Select(p => new { p.SalesInvoiceId, InvoiceDate = p.SalesInvoice!.InvoiceDate, p.PaymentDate, p.PaymentMethod, p.Amount }) - .ToListAsync(ct); - - var slipCost = await CostByDocAsync(DocumentTypes.SalesSlip, slips.Select(s => s.SalesSlipId), ct); - var bundleCost = await CostByDocAsync(DocumentTypes.BundleSale, bundles.Select(b => b.BundleSaleId), ct); - var invoiceCost = await CostByDocAsync(DocumentTypes.SalesInvoice, invoices.Select(i => i.SalesInvoiceId), ct); - - var expenses = await PostedExpenses(from, to, branch) - .Select(e => new { e.ExpenseDate, e.Amount }) - .ToListAsync(ct); + var expenses = await _report.ListPostedExpensesAsync(from, to, branch, ct); // Branch is already the effective one, so the cash service scopes it the same way. var cashByDay = (await _cash.GetRangeAsync(from, to, branch, ct)).ToDictionary(c => c.Date); @@ -159,11 +106,11 @@ public sealed class DayEndReportService : IDayEndReportService var sales = new List(slips.Count + bundles.Count + invoices.Count); sales.AddRange(slips.Select(s => new Sale( - "Slip", s.SalesSlipId, s.SlipNo, s.SlipDate, LocalDay(s.SlipDate), s.CustomerSnapshotName, + "Slip", s.SalesSlipId, s.SlipNo, s.SlipDate, LocalDay(s.SlipDate), s.CustomerName, s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount, 0m, PaidBy(s.CashAmount, s.CardAmount, 0m, null), slipCost.GetValueOrDefault(s.SalesSlipId)))); sales.AddRange(bundles.Select(b => new Sale( - "Bundle", b.BundleSaleId, b.BundleNo, b.BundleDate, LocalDay(b.BundleDate), b.CustomerSnapshotName, + "Bundle", b.BundleSaleId, b.BundleNo, b.BundleDate, LocalDay(b.BundleDate), b.CustomerName, b.GrandTotal, b.GrandTotal, b.CashAmount, b.CardAmount, 0m, PaidBy(b.CashAmount, b.CardAmount, 0m, null), bundleCost.GetValueOrDefault(b.BundleSaleId)))); foreach (var i in invoices) @@ -175,7 +122,7 @@ public sealed class DayEndReportService : IDayEndReportService var other = others.Sum(p => p.Amount); var otherMethods = others.Select(p => p.PaymentMethod).Distinct().ToList(); sales.Add(new Sale( - "Invoice", i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, LocalDay(i.InvoiceDate), i.CustomerSnapshotName, + "Invoice", i.SalesInvoiceId, i.InvoiceNo, i.InvoiceDate, LocalDay(i.InvoiceDate), i.CustomerName, i.NetPayable, i.PaidAmount, cash, card, other, PaidBy(cash, card, other, otherMethods.Count == 1 ? otherMethods[0] : null), invoiceCost.GetValueOrDefault(i.SalesInvoiceId))); @@ -195,23 +142,7 @@ public sealed class DayEndReportService : IDayEndReportService days.Add(acc.ToDto(d)); } - return new Loaded(days, sales); - } - - /// Sum of the stock ledger's Out value per document — the FIFO cost the sale consumed. - private async Task> CostByDocAsync(string docType, IEnumerable docIds, CancellationToken ct) - { - var result = new Dictionary(); - foreach (var chunk in docIds.Distinct().Chunk(IdChunk)) - { - var rows = await _ledger.Query().AsNoTracking() - .Where(l => l.SourceDocType == docType && l.Direction == Direction.Out && chunk.Contains(l.SourceDocId)) - .GroupBy(l => l.SourceDocId) - .Select(g => new { DocId = g.Key, Cost = g.Sum(l => l.Value) }) - .ToListAsync(ct); - foreach (var r in rows) result[r.DocId] = r.Cost; - } - return result; + return new Loaded(days, sales, expenses); } private static string PaidBy(decimal cash, decimal card, decimal other, SalesPaymentMethod? singleOther) @@ -224,13 +155,6 @@ public sealed class DayEndReportService : IDayEndReportService return singleOther == SalesPaymentMethod.Cheque ? "Cheque" : "Bank transfer"; } - private IQueryable PostedExpenses(DateOnly from, DateOnly to, int? branch) - { - var q = _expenses.Query().AsNoTracking() - .Where(e => e.Status == ExpenseStatus.Posted && e.ExpenseDate >= from && e.ExpenseDate <= to); - return branch is null ? q : q.Where(e => e.BranchId == branch); - } - private DateOnly LocalDay(DateTime utc) => _clock.LocalDay(utc); /// Flows are summed; cash in hand is a balance, so the range opens with its first day and closes with its last. diff --git a/Backend/ERPCore/Services/ExpensePaymentService.cs b/Backend/ERPCore/Services/ExpensePaymentService.cs index 49b49a6..448e2a7 100644 --- a/Backend/ERPCore/Services/ExpensePaymentService.cs +++ b/Backend/ERPCore/Services/ExpensePaymentService.cs @@ -7,7 +7,6 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -18,11 +17,11 @@ namespace ERPCore.Services; /// all sharing that journal number. Same call-GL-before-commit pattern as /// , so a rejected/unreachable GL post rolls the /// whole payment back. Post & Pay runs the accrual and a full or partial payment in one transaction. +/// Its own queries go through . /// public sealed class ExpensePaymentService : IExpensePaymentService { - private readonly IRepository _expenses; - private readonly IRepository _payments; + private readonly IExpensePaymentRepository _payments; private readonly ExpenseService _expenseService; private readonly IGeneralLedgerService _gl; private readonly ICurrentUser _currentUser; @@ -30,10 +29,9 @@ public sealed class ExpensePaymentService : IExpensePaymentService private readonly IGlAccountConfigService _glAccounts; public ExpensePaymentService( - IRepository expenses, IRepository payments, ExpenseService expenseService, + IExpensePaymentRepository payments, ExpenseService expenseService, IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts) { - _expenses = expenses; _payments = payments; _expenseService = expenseService; _gl = gl; @@ -93,13 +91,10 @@ public sealed class ExpensePaymentService : IExpensePaymentService public async Task> ListAsync(int expenseId, CancellationToken ct = default) { - if (!await _expenses.Query().AnyAsync(x => x.ExpenseId == expenseId, ct)) + if (!await _payments.ExpenseExistsAsync(expenseId, ct)) throw new NotFoundException($"Expense {expenseId} was not found."); - var payments = await _payments.Query().AsNoTracking() - .Where(p => p.ExpenseId == expenseId) - .OrderByDescending(p => p.ExpensePaymentId) - .ToListAsync(ct); + var payments = await _payments.ListForExpenseAsync(expenseId, ct); return payments.Select(ExpenseService.MapPayment).ToList(); } diff --git a/Backend/ERPCore/Services/ExpenseService.cs b/Backend/ERPCore/Services/ExpenseService.cs index 699c76e..d59ac21 100644 --- a/Backend/ERPCore/Services/ExpenseService.cs +++ b/Backend/ERPCore/Services/ExpenseService.cs @@ -10,7 +10,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IExpenseRepository namespace ERPCore.Services; @@ -20,13 +20,12 @@ namespace ERPCore.Services; /// real GL journal entry posted before commit — same atomicity approach as /// , so a GL rejection rolls the post back. /// Payments settle the accrual (). Cancelling a Posted -/// expense with no payments posts the reversing entry. +/// expense with no payments posts the reversing entry. Queries go through ; +/// every write runs inside like . /// public sealed class ExpenseService : IExpenseService { - private readonly IRepository _expenses; - private readonly IRepository _titles; - private readonly IRepository _vendors; + private readonly IExpenseRepository _expenses; private readonly ICurrentUser _currentUser; private readonly INumberSequenceService _numbers; private readonly IBranchConfigService _branchConfig; @@ -35,13 +34,10 @@ public sealed class ExpenseService : IExpenseService private readonly IGlAccountConfigService _glAccounts; public ExpenseService( - IRepository expenses, IRepository titles, IRepository vendors, - ICurrentUser currentUser, INumberSequenceService numbers, IBranchConfigService branchConfig, + IExpenseRepository expenses, ICurrentUser currentUser, INumberSequenceService numbers, IBranchConfigService branchConfig, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { _expenses = expenses; - _titles = titles; - _vendors = vendors; _currentUser = currentUser; _numbers = numbers; _branchConfig = branchConfig; @@ -52,30 +48,8 @@ public sealed class ExpenseService : IExpenseService public async Task> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default) { - IQueryable q = _expenses.Query().AsNoTracking().Include(x => x.Category).Include(x => x.Title); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%") - || (x.Payee != null && EF.Functions.Like(x.Payee, $"%{term}%")) - || (x.BillReference != null && EF.Functions.Like(x.BillReference, $"%{term}%"))); - } - if (filter.Status is not null) q = q.Where(x => x.Status == filter.Status); - if (filter.PaymentStatus is not null) q = q.Where(x => x.Status == ExpenseStatus.Posted && x.PaymentStatus == filter.PaymentStatus); - if (filter.CategoryId is not null) q = q.Where(x => x.ExpenseCategoryId == filter.CategoryId); - if (filter.TitleId is not null) q = q.Where(x => x.ExpenseTitleId == filter.TitleId); - if (filter.From is not null) q = q.Where(x => x.ExpenseDate >= filter.From); - if (filter.To is not null) q = q.Where(x => x.ExpenseDate <= filter.To); - if (filter.OverdueOnly) - { - var today = DateOnly.FromDateTime(DateTime.UtcNow); - q = q.Where(x => x.Status == ExpenseStatus.Posted && x.BalanceAmount > 0 && x.DueDate != null && x.DueDate < today); - } - - var total = await q.CountAsync(ct); - var rows = await q.OrderByDescending(x => x.ExpenseDate).ThenByDescending(x => x.ExpenseId) - .Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); - + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var (rows, total) = await _expenses.ListPagedAsync(query.Q, filter, today, query.Skip, query.PageSize, ct); return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); } @@ -129,26 +103,41 @@ public sealed class ExpenseService : IExpenseService var vendorName = await ValidateVendorAsync(request.VendorId, ct); ValidateDates(request); - if (request.BranchId > 0 && request.BranchId != expense.BranchId) - expense.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); + var branchId = request.BranchId > 0 && request.BranchId != expense.BranchId + ? await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct) + : expense.BranchId; - Apply(expense, request, vendorName); - expense.Category = title.Category; - expense.Title = title; - expense.UpdatedAt = DateTime.UtcNow; + try + { + await _uow.ExecuteInTransactionAsync(_ => + { + expense.BranchId = branchId; + Apply(expense, request, vendorName); + expense.Category = title.Category; + expense.Title = title; + expense.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The expense was modified by another request.", 412); + } - await SaveAsync(ct); return new ETagged(Map(expense), expense.RowVersion); } public async Task DeleteAsync(int expenseId, CancellationToken ct = default) { - var expense = await _expenses.Query().FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct) + var expense = await _expenses.GetByIdAsync(expenseId, ct) ?? throw new NotFoundException($"Expense {expenseId} was not found."); EnsureDraft(expense, "deleted"); - _expenses.Remove(expense); - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + _expenses.Remove(expense); + return Task.CompletedTask; + }, ct); } public async Task PostAsync(int expenseId, CancellationToken ct = default) @@ -251,22 +240,10 @@ public sealed class ExpenseService : IExpenseService if (end < start) throw new DomainException(ErrorCodes.Validation, "'to' must not be before 'from'.", 400); - var posted = _expenses.Query().AsNoTracking().Where(x => x.Status == ExpenseStatus.Posted); - - var inRange = await posted - .Where(x => x.ExpenseDate >= start && x.ExpenseDate <= end) - .Select(x => new - { - x.ExpenseCategoryId, CategoryName = x.Category!.Name, CategorySort = x.Category.SortOrder, - x.ExpenseTitleId, TitleName = x.Title!.Name, x.Amount, x.PaidAmount, x.BalanceAmount - }) - .ToListAsync(ct); - - var outstanding = await posted.Where(x => x.BalanceAmount > 0) - .Select(x => new { x.BalanceAmount, x.DueDate }) - .ToListAsync(ct); + var inRange = await _expenses.ListPostedInRangeAsync(start, end, ct); + var outstanding = await _expenses.ListOutstandingAsync(ct); var overdue = outstanding.Where(x => x.DueDate != null && x.DueDate < today).ToList(); - var draftCount = await _expenses.Query().CountAsync(x => x.Status == ExpenseStatus.Draft, ct); + var draftCount = await _expenses.CountDraftsAsync(ct); var categories = inRange .GroupBy(x => new { x.ExpenseCategoryId, x.CategoryName, x.CategorySort }) @@ -286,22 +263,14 @@ public sealed class ExpenseService : IExpenseService categories); } + /// An expense with its category, title, vendor and payments. Shared with . internal Task LoadAsync(int expenseId, bool tracking, CancellationToken ct) - { - var q = _expenses.Query() - .Include(x => x.Category) - .Include(x => x.Title) - .Include(x => x.Vendor) - .Include(x => x.Payments); - return (tracking ? q : q.AsNoTracking()).FirstOrDefaultAsync(x => x.ExpenseId == expenseId, ct); - } + => _expenses.GetWithDetailsAsync(expenseId, tracking, ct); private async Task ValidateTitleAsync(ExpenseRequestBase request, CancellationToken ct) { - // Tracked on purpose: UpdateAsync assigns this to the tracked expense's navigations, and - // identity resolution hands back the instance the expense already loaded. - var title = await _titles.Query().Include(t => t.Category) - .FirstOrDefaultAsync(t => t.ExpenseTitleId == request.ExpenseTitleId, ct) + // Tracked on purpose (see IExpenseRepository.GetTitleWithCategoryAsync). + var title = await _expenses.GetTitleWithCategoryAsync(request.ExpenseTitleId, ct) ?? throw new DomainException(ErrorCodes.Validation, $"Expense title {request.ExpenseTitleId} does not exist.", 422); if (title.ExpenseCategoryId != request.ExpenseCategoryId) @@ -315,7 +284,7 @@ public sealed class ExpenseService : IExpenseService private async Task ValidateVendorAsync(int? vendorId, CancellationToken ct) { if (vendorId is null) return null; - return await _vendors.Query().AsNoTracking().Where(v => v.VendorId == vendorId).Select(v => v.Name).FirstOrDefaultAsync(ct) + return await _expenses.GetVendorNameAsync(vendorId.Value, ct) ?? throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422); } @@ -352,18 +321,6 @@ public sealed class ExpenseService : IExpenseService throw new DomainException(ErrorCodes.ExpenseNotDraft, $"Expense {expense.DocNo} is {expense.Status} and cannot be {verb}.", 409); } - private async Task SaveAsync(CancellationToken ct) - { - try - { - await _uow.SaveChangesAsync(ct); - } - catch (DbUpdateConcurrencyException) - { - throw new DomainException(ErrorCodes.ConcurrencyConflict, "The expense was modified by another request.", 412); - } - } - private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); internal static ExpensePaymentStatus DerivePaymentStatus(decimal paidAmount, decimal balanceAmount) => diff --git a/Backend/ERPCore/Services/ExpenseSetupService.cs b/Backend/ERPCore/Services/ExpenseSetupService.cs index 54f27d8..3a49b25 100644 --- a/Backend/ERPCore/Services/ExpenseSetupService.cs +++ b/Backend/ERPCore/Services/ExpenseSetupService.cs @@ -5,7 +5,6 @@ using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -15,109 +14,108 @@ namespace ERPCore.Services; /// deactivated, and a title's GL account code can be corrected — while user-added titles /// are fully editable. Nothing is hard-deleted: expenses reference titles, so retiring one /// means setting it Inactive (it stops being offered on new expenses). +/// Queries go through ; writes run inside +/// like . /// public sealed class ExpenseSetupService : IExpenseSetupService { - private readonly IRepository _categories; - private readonly IRepository _titles; + private readonly IExpenseSetupRepository _setup; private readonly IUnitOfWork _uow; - public ExpenseSetupService(IRepository categories, IRepository titles, IUnitOfWork uow) + public ExpenseSetupService(IExpenseSetupRepository setup, IUnitOfWork uow) { - _categories = categories; - _titles = titles; + _setup = setup; _uow = uow; } public async Task> ListAsync(bool includeInactive, CancellationToken ct = default) { - var categories = await _categories.Query().AsNoTracking() - .Include(c => c.Titles) - .Where(c => includeInactive || c.Status == EntityStatus.Active) - .OrderBy(c => c.SortOrder).ThenBy(c => c.Name) - .ToListAsync(ct); - + var categories = await _setup.ListWithTitlesAsync(includeInactive, ct); return categories.Select(c => Map(c, includeInactive)).ToList(); } public async Task UpdateCategoryAsync(int expenseCategoryId, UpdateExpenseCategoryRequest request, CancellationToken ct = default) { - var category = await _categories.Query().Include(c => c.Titles) - .FirstOrDefaultAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct) + var category = await _setup.GetWithTitlesForUpdateAsync(expenseCategoryId, ct) ?? throw new NotFoundException($"Expense category {expenseCategoryId} was not found."); var name = request.Name.Trim(); - if (name != category.Name) - { - if (category.IsSystem) - throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{category.Name}' is a built-in category and cannot be renamed.", 409); - if (await _categories.Query().AnyAsync(c => c.Name == name && c.ExpenseCategoryId != expenseCategoryId, ct)) - throw new DomainException(ErrorCodes.DuplicateExpenseCategory, $"An expense category named '{name}' already exists.", 409); - category.Name = name; - } + if (name != category.Name && category.IsSystem) + throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{category.Name}' is a built-in category and cannot be renamed.", 409); - category.Status = request.Status; - category.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(async token => + { + if (name != category.Name) + { + if (await _setup.CategoryNameExistsAsync(name, expenseCategoryId, token)) + throw new DomainException(ErrorCodes.DuplicateExpenseCategory, $"An expense category named '{name}' already exists.", 409); + category.Name = name; + } + + category.Status = request.Status; + category.UpdatedAt = DateTime.UtcNow; + }, ct); return Map(category, includeInactive: true); } public async Task CreateTitleAsync(int expenseCategoryId, CreateExpenseTitleRequest request, CancellationToken ct = default) { - if (!await _categories.Query().AnyAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct)) + if (!await _setup.ExistsAsync(expenseCategoryId, ct)) throw new NotFoundException($"Expense category {expenseCategoryId} was not found."); var name = request.Name.Trim(); - await EnsureTitleNameIsUniqueAsync(expenseCategoryId, name, excludingTitleId: null, ct); - var nextSort = (await _titles.Query().Where(t => t.ExpenseCategoryId == expenseCategoryId) - .MaxAsync(t => (int?)t.SortOrder, ct) ?? 0) + 1; - var title = new ExpenseTitle + var title = await _uow.ExecuteInTransactionAsync(async token => { - ExpenseCategoryId = expenseCategoryId, - Name = name, - GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(), - IsSystem = false, - SortOrder = nextSort, - CreatedAt = DateTime.UtcNow - }; - await _titles.AddAsync(title, ct); - await _uow.SaveChangesAsync(ct); + await EnsureTitleNameIsUniqueAsync(expenseCategoryId, name, excludingTitleId: null, token); + + var entity = new ExpenseTitle + { + ExpenseCategoryId = expenseCategoryId, + Name = name, + GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(), + IsSystem = false, + SortOrder = await _setup.MaxTitleSortOrderAsync(expenseCategoryId, token) + 1, + CreatedAt = DateTime.UtcNow + }; + await _setup.AddTitleAsync(entity, token); + return entity; + }, ct); return Map(title); } public async Task UpdateTitleAsync(int expenseTitleId, UpdateExpenseTitleRequest request, CancellationToken ct = default) { - var title = await _titles.Query().FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct) + var title = await _setup.GetTitleForUpdateAsync(expenseTitleId, ct) ?? throw new NotFoundException($"Expense title {expenseTitleId} was not found."); var name = request.Name.Trim(); - if (name != title.Name) - { - if (title.IsSystem) - throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{title.Name}' is a built-in title and cannot be renamed.", 409); - await EnsureTitleNameIsUniqueAsync(title.ExpenseCategoryId, name, excludingTitleId: expenseTitleId, ct); - title.Name = name; - } + if (name != title.Name && title.IsSystem) + throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{title.Name}' is a built-in title and cannot be renamed.", 409); - // The GL code is editable on system rows too — the seeded codes are placeholders. - // Already-posted expenses keep the code they snapshotted at post time. - title.GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(); - title.Status = request.Status; - title.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(async token => + { + if (name != title.Name) + { + await EnsureTitleNameIsUniqueAsync(title.ExpenseCategoryId, name, excludingTitleId: expenseTitleId, token); + title.Name = name; + } + + // The GL code is editable on system rows too — the seeded codes are placeholders. + // Already-posted expenses keep the code they snapshotted at post time. + title.GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(); + title.Status = request.Status; + title.UpdatedAt = DateTime.UtcNow; + }, ct); return Map(title); } private async Task EnsureTitleNameIsUniqueAsync(int expenseCategoryId, string name, int? excludingTitleId, CancellationToken ct) { - var duplicate = await _titles.Query().AnyAsync(t => - t.ExpenseCategoryId == expenseCategoryId && t.Name == name - && (excludingTitleId == null || t.ExpenseTitleId != excludingTitleId), ct); - if (duplicate) + if (await _setup.TitleNameExistsAsync(expenseCategoryId, name, excludingTitleId, ct)) throw new DomainException(ErrorCodes.DuplicateExpenseTitle, $"A title named '{name}' already exists in this category.", 409); } diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 0f563d1..e080122 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -10,7 +10,6 @@ using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; using ERPCore.System.Time; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -19,27 +18,16 @@ namespace ERPCore.Services; /// for PO-based receipts — 02-SECURITY C.3; over-receipt tolerance enforced). /// Confirm posts FIFO layers + inbound ledger and updates PO receipts inside one /// UoW transaction (NFR-02/05). Quantities are converted to the item's base UOM -/// for the ledger/layers (FR-MD-03). +/// for the ledger/layers (FR-MD-03). Queries go through ; every write runs +/// inside like . /// public sealed class GrnService : IGrnService { // Phase 1: block any receipt beyond the PO line's open quantity (configurable later, NFR-10). private const decimal OverReceiptTolerance = 0m; - private readonly IRepository _grns; - private readonly IRepository _pos; - private readonly IRepository _poLines; - private readonly IRepository _items; - private readonly IRepository _warehouses; - private readonly IRepository _bins; - private readonly IRepository _vendors; - private readonly IRepository _batches; - private readonly IRepository _serials; - private readonly IRepository _serialMovements; + private readonly IGrnRepository _grns; private readonly ISerialTrackingService _serialTracking; - private readonly IRepository _layers; - private readonly IRepository _ledger; - private readonly IRepository _taxRates; private readonly IFifoCostingService _fifo; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -52,28 +40,12 @@ public sealed class GrnService : IGrnService private readonly IProcurementConfigService _procurementConfig; public GrnService( - IRepository grns, IRepository pos, IRepository poLines, - IRepository items, IRepository warehouses, - IRepository bins, IRepository vendors, IRepository batches, IRepository serials, IRepository serialMovements, ISerialTrackingService serialTracking, - IRepository layers, IRepository ledger, IRepository taxRates, - IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig, + IGrnRepository grns, ISerialTrackingService serialTracking, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig, IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts, IProcurementConfigService procurementConfig) { _grns = grns; - _pos = pos; - _poLines = poLines; - _items = items; - _warehouses = warehouses; - _bins = bins; - _vendors = vendors; - _batches = batches; - _serials = serials; - _serialMovements = serialMovements; _serialTracking = serialTracking; - _layers = layers; - _ledger = ledger; - _taxRates = taxRates; _fifo = fifo; _numbers = numbers; _currentUser = currentUser; @@ -90,60 +62,17 @@ public sealed class GrnService : IGrnService PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default) { - var q = _grns.Query().AsNoTracking(); - - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - // Doc no., the PO's doc no., or the vendor's name / code (the vendor is the table's first column). - var vendorIdsMatching = _vendors.Query() - .Where(v => EF.Functions.Like(v.Name, $"%{term}%") || EF.Functions.Like(v.Code, $"%{term}%")) - .Select(v => v.VendorId); - var poIdsMatching = _pos.Query() - .Where(p => EF.Functions.Like(p.DocNo, $"%{term}%")) - .Select(p => p.PoId); - q = q.Where(g => EF.Functions.Like(g.DocNo, $"%{term}%") - || vendorIdsMatching.Contains(g.VendorId) - || (g.PoId != null && poIdsMatching.Contains(g.PoId.Value))); - } - if (status is not null) q = q.Where(g => g.Status == status); - if (poId is not null) q = q.Where(g => g.PoId == poId); - if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId); - if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId); - // Warehouses are shared across branches — GRN carries its own BranchId, set at creation. - if (branchId is not null) q = q.Where(g => g.BranchId == branchId); // Created date, as a business (Colombo) day: [start of `from`, start of the day after `to`). - if (from is DateOnly fromDay) - { - var start = BusinessClock.StartOfDayUtc(fromDay); - q = q.Where(g => g.CreatedAt >= start); - } - if (to is DateOnly toDay) - { - var end = BusinessClock.StartOfDayUtc(toDay.AddDays(1)); - q = q.Where(g => g.CreatedAt < end); - } - - var total = await q.CountAsync(ct); - var rows = await ApplySort(q, query.Sort) - .Skip(query.Skip).Take(query.PageSize) - .Select(g => new - { - g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status, - g.CreatedBy, g.CreatedAt, g.PostedAt, LineCount = g.Lines.Count, TotalValue = g.Lines.Sum(l => l.LineTotal), - g.PurchaseInvoiceId, PurchaseInvoiceNo = g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null - }) - .ToListAsync(ct); + var criteria = new GrnListCriteria( + query.Q, status, poId, vendorId, warehouseId, branchId, + from is DateOnly fromDay ? BusinessClock.StartOfDayUtc(fromDay) : null, + to is DateOnly toDay ? BusinessClock.StartOfDayUtc(toDay.AddDays(1)) : null, + query.Sort); + var (rows, total) = await _grns.ListPagedAsync(criteria, query.Skip, query.PageSize, ct); var flags = await _procurementConfig.GetFlagsAsync(ct); - var vendorIds = rows.Select(r => r.VendorId).Distinct().ToList(); - var vendorMap = await _vendors.Query().AsNoTracking() - .Where(v => vendorIds.Contains(v.VendorId)) - .ToDictionaryAsync(v => v.VendorId, ct); - var poIds = rows.Where(r => r.PoId != null).Select(r => r.PoId!.Value).Distinct().ToList(); - var poDocNos = await _pos.Query().AsNoTracking() - .Where(p => poIds.Contains(p.PoId)) - .ToDictionaryAsync(p => p.PoId, p => p.DocNo, ct); + var vendorMap = await _grns.GetVendorsAsync(rows.Select(r => r.VendorId).Distinct().ToList(), ct); + var poDocNos = await _grns.GetPoDocNosAsync(rows.Where(r => r.PoId != null).Select(r => r.PoId!.Value).Distinct().ToList(), ct); var dtos = rows.Select(r => new GrnSummaryDto( r.GrnId, r.DocNo, r.PoId, r.VendorId, r.WarehouseId, r.BranchId, r.Status, @@ -161,37 +90,9 @@ public sealed class GrnService : IGrnService return PagedResponse.Create(dtos, query.Page, query.PageSize, total); } - /// - /// Server-side sort so paging stays correct: created / vendor / total / status, a leading - /// - for descending. Default (and any unknown key) is newest first; GrnId is the tiebreaker. - /// - private IQueryable ApplySort(IQueryable q, string? sort) - { - var key = (sort ?? "-created").Trim(); - var desc = key.StartsWith('-'); - key = key.TrimStart('-', '+').ToLowerInvariant(); - - IOrderedQueryable ordered = key switch - { - "vendor" => desc - ? q.OrderByDescending(g => _vendors.Query().Where(v => v.VendorId == g.VendorId).Select(v => v.Name).FirstOrDefault()) - : q.OrderBy(g => _vendors.Query().Where(v => v.VendorId == g.VendorId).Select(v => v.Name).FirstOrDefault()), - "total" => desc ? q.OrderByDescending(g => g.Lines.Sum(l => l.LineTotal)) : q.OrderBy(g => g.Lines.Sum(l => l.LineTotal)), - "status" => desc ? q.OrderByDescending(g => g.Status) : q.OrderBy(g => g.Status), - "created" => desc ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt), - _ => q.OrderByDescending(g => g.CreatedAt) - }; - return ordered.ThenByDescending(g => g.GrnId); - } - public async Task GetAsync(int grnId, CancellationToken ct = default) { - var grn = await _grns.Query().AsNoTracking() - .Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers) - .Include(g => g.Lines).ThenInclude(l => l.Serials) - .Include(g => g.Lines).ThenInclude(l => l.Batch) - .Include(g => g.PurchaseInvoice) - .FirstOrDefaultAsync(g => g.GrnId == grnId, ct); + var grn = await _grns.GetWithDetailsAsync(grnId, ct); return grn is null ? null : Map(grn); } @@ -201,7 +102,7 @@ public sealed class GrnService : IGrnService if (await _grnConfig.GetForcedWarehouseIdAsync(ct) is int forcedWarehouseId) request.WarehouseId = forcedWarehouseId; - if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct)) + if (!await _grns.WarehouseExistsAsync(request.WarehouseId, ct)) throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422); // Resolve vendor + PO context. @@ -209,8 +110,7 @@ public sealed class GrnService : IGrnService int vendorId; if (request.PoId is not null) { - po = await _pos.Query().AsNoTracking().Include(p => p.Lines) - .FirstOrDefaultAsync(p => p.PoId == request.PoId, ct) + po = await _grns.GetPoWithLinesAsync(request.PoId.Value, ct) ?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422); if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived)) throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against."); @@ -220,7 +120,7 @@ public sealed class GrnService : IGrnService { if (request.VendorId is null) throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422); - if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct)) + if (!await _grns.VendorExistsAsync(request.VendorId.Value, ct)) throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422); vendorId = request.VendorId.Value; } @@ -241,9 +141,9 @@ public sealed class GrnService : IGrnService foreach (var input in request.Lines) { - var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct) + var item = await _grns.GetItemAsync(input.ItemId, ct) ?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422); - if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct)) + if (input.BinId is int binId && !await _grns.BinIsInWarehouseAsync(binId, request.WarehouseId, ct)) throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422); // Cost: for a PO line, the PO price is used unless an override is entered (then it @@ -281,7 +181,7 @@ public sealed class GrnService : IGrnService // equivalent of this same "Item.TaxRate drives the default, don't hand-type it" rule. // GRN Configuration "Allow VAT" off: every line is 0% — the item's tax rate is not applied either. var vatPct = !allowVat ? 0m : input.VatPct ?? (item.TaxRateId is int taxRateId - ? await _taxRates.Query().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct) + ? await _grns.GetTaxPercentageAsync(taxRateId, ct) : 0m); var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero); @@ -337,9 +237,7 @@ public sealed class GrnService : IGrnService public async Task ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default) { - var grn = await _grns.Query() - .Include(g => g.Lines).ThenInclude(l => l.Serials) - .FirstOrDefaultAsync(g => g.GrnId == grnId, ct) + var grn = await _grns.GetForConfirmAsync(grnId, ct) ?? throw new NotFoundException($"GRN {grnId} was not found."); // Idempotent replay: an already-confirmed GRN returns its existing result. @@ -394,7 +292,7 @@ public sealed class GrnService : IGrnService if (line.PoLineId is not null) { - var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token); + var poLine = await _grns.GetPoLineForUpdateAsync(line.PoLineId.Value, token); if (poLine is not null) poLine.QtyReceived += line.Qty; } } @@ -448,9 +346,7 @@ public sealed class GrnService : IGrnService public async Task ReleaseLineAsync(int grnId, int grnLineId, string action, CancellationToken ct = default) { - var grn = await _grns.Query() - .Include(g => g.Lines) - .FirstOrDefaultAsync(g => g.GrnId == grnId, ct) + var grn = await _grns.GetWithLinesForUpdateAsync(grnId, ct) ?? throw new NotFoundException($"GRN {grnId} was not found."); var line = grn.Lines.FirstOrDefault(l => l.GrnLineId == grnLineId) @@ -461,23 +357,23 @@ public sealed class GrnService : IGrnService if (line.HoldStatus != HoldStatus.OnHold) throw new ConflictException($"GRN line {grnLineId} is {line.HoldStatus}, not OnHold."); - if (string.Equals(action, "Release", StringComparison.OrdinalIgnoreCase)) + // Release: the stock becomes issuable. 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). + var next = string.Equals(action, "Release", StringComparison.OrdinalIgnoreCase) + ? HoldStatus.Available + : HoldStatus.Rejected; + + await _uow.ExecuteInTransactionAsync(_ => { - line.HoldStatus = HoldStatus.Available; - await _uow.SaveChangesAsync(ct); - return new ReleaseLineResultDto(grnLineId, HoldStatus.Available); - } + line.HoldStatus = next; + return Task.CompletedTask; + }, 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); + return new ReleaseLineResultDto(grnLineId, next); } private async Task ResolveBatchAsync( @@ -490,7 +386,7 @@ public sealed class GrnService : IGrnService var key = (item.ItemId, batch.BatchNo.Trim()); if (cache.TryGetValue(key, out var cached)) return cached; - var existing = await _batches.Query().FirstOrDefaultAsync(b => b.ItemId == item.ItemId && b.BatchNo == key.Item2, ct); + var existing = await _grns.FindBatchAsync(item.ItemId, key.Item2, ct); if (existing is not null) { cache[key] = existing; @@ -498,7 +394,7 @@ public sealed class GrnService : IGrnService } var created = new Batch { ItemId = item.ItemId, BatchNo = key.Item2, ExpiryDate = batch.ExpiryDate }; - await _batches.AddAsync(created, ct); + await _grns.AddBatchAsync(created, ct); cache[key] = created; return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges } @@ -567,9 +463,7 @@ public sealed class GrnService : IGrnService private async Task EnsureSerialsFreeAsync(int itemId, string sku, List numbers, CancellationToken ct) { - var taken = await _serials.Query().AsNoTracking() - .Where(s => s.ItemId == itemId && numbers.Contains(s.SerialNo)) - .Select(s => s.SerialNo).Take(5).ToListAsync(ct); + var taken = await _grns.ListTakenSerialsAsync(itemId, numbers, take: 5, ct); if (taken.Count > 0) throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial number(s) already exist for item {sku}: {string.Join(", ", taken)}.", 409); @@ -582,7 +476,8 @@ public sealed class GrnService : IGrnService /// private async Task> ReceiveSerialsAsync(Grn grn, GrnLine line, decimal unitCost, DateTime now, CancellationToken ct) { - var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, ct); + var item = await _grns.GetItemAsync(line.ItemId, ct) + ?? throw new NotFoundException($"Item {line.ItemId} was not found."); await EnsureSerialsFreeAsync(item.ItemId, item.Sku, line.Serials.Select(s => s.SerialNo).ToList(), ct); var warrantyMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null; @@ -601,9 +496,9 @@ public sealed class GrnService : IGrnService CreatedAt = now, WarrantyPeriodMonths = warrantyMonths }; - await _serials.AddAsync(serial, ct); + await _grns.AddSerialAsync(serial, ct); captured.Serial = serial; // FK fixed up on SaveChanges - await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.Grn, grn.GrnId, _currentUser.AuditUserId, now), ct); + await _grns.AddSerialMovementAsync(SerialMovement.Of(serial, null, DocumentTypes.Grn, grn.GrnId, _currentUser.AuditUserId, now), ct); var layer = await _fifo.CreateInboundLayerAsync( line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, 1m, unitCost, now, ct); @@ -616,7 +511,7 @@ public sealed class GrnService : IGrnService private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct) { if (poId is null) return; - var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct); + var po = await _grns.GetPoWithLinesForUpdateAsync(poId.Value, ct); if (po is null) return; po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty) @@ -628,17 +523,13 @@ public sealed class GrnService : IGrnService private async Task GetPoStatusAsync(int? poId, CancellationToken ct) { if (poId is null) return null; - return await _pos.Query().AsNoTracking().Where(p => p.PoId == poId).Select(p => (PurchaseOrderStatus?)p.Status).FirstOrDefaultAsync(ct); + return await _grns.GetPoStatusAsync(poId.Value, ct); } private async Task BuildConfirmResultAsync(Grn grn, CancellationToken ct) { - var lineIds = grn.Lines.Select(l => l.GrnLineId).ToList(); - var layers = await _layers.Query().AsNoTracking() - .Where(l => l.GrnLineId != null && lineIds.Contains(l.GrnLineId.Value)).ToListAsync(ct); - var ledgerRefs = await _ledger.Query().AsNoTracking() - .Where(l => l.SourceDocType == DocumentTypes.Grn && l.SourceDocId == grn.GrnId && l.Direction == Direction.In) - .Select(l => l.LedgerId).ToListAsync(ct); + var layers = await _grns.ListLayersForLinesAsync(grn.Lines.Select(l => l.GrnLineId).ToList(), ct); + var ledgerRefs = await _grns.ListInboundLedgerIdsAsync(grn.GrnId, ct); return new GrnConfirmResultDto( grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, diff --git a/Backend/ERPCore/Services/ItemImageService.cs b/Backend/ERPCore/Services/ItemImageService.cs index 52c2bcd..e5644f1 100644 --- a/Backend/ERPCore/Services/ItemImageService.cs +++ b/Backend/ERPCore/Services/ItemImageService.cs @@ -5,7 +5,6 @@ using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -14,7 +13,8 @@ namespace ERPCore.Services; /// content-type cross-check and a size cap, server-authoritative — narrowed to web image /// formats, plus a per-item count cap. Uploads are refused with CONFIG_DISABLED while /// ProductConfig.ItemImagesEnabled is off; listing, viewing and deleting stay open so -/// images added earlier remain manageable. +/// images added earlier remain manageable. Queries go through ; +/// writes run inside like . /// public sealed class ItemImageService : IItemImageService { @@ -29,18 +29,14 @@ public sealed class ItemImageService : IItemImageService [".webp"] = "image/webp" }; - private readonly IRepository _images; - private readonly IRepository _items; + private readonly IItemImageRepository _images; private readonly IProductConfigService _config; private readonly IFileStorageService _storage; private readonly IUnitOfWork _uow; - public ItemImageService( - IRepository images, IRepository items, IProductConfigService config, - IFileStorageService storage, IUnitOfWork uow) + public ItemImageService(IItemImageRepository images, IProductConfigService config, IFileStorageService storage, IUnitOfWork uow) { _images = images; - _items = items; _config = config; _storage = storage; _uow = uow; @@ -49,11 +45,7 @@ public sealed class ItemImageService : IItemImageService public async Task> ListAsync(int itemId, CancellationToken ct = default) { await EnsureItemExistsAsync(itemId, ct); - return await _images.Query().AsNoTracking() - .Where(i => i.ItemId == itemId) - .OrderBy(i => i.SortOrder).ThenBy(i => i.ItemImageId) - .Select(i => new ItemImageDto(i.ItemImageId, i.ItemId, i.OriginalFileName, i.ContentType, i.SizeBytes, i.SortOrder, i.UploadedAt)) - .ToListAsync(ct); + return (await _images.ListForItemAsync(itemId, ct)).Select(Map).ToList(); } public async Task UploadAsync( @@ -75,37 +67,47 @@ public sealed class ItemImageService : IItemImageService throw new DomainException(ErrorCodes.FileTooLarge, $"Image exceeds the maximum allowed size of {MaxSizeBytes / (1024 * 1024)} MB.", 413); - var existing = await _images.Query().Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct); - if (existing.Count >= MaxImagesPerItem) - throw new DomainException(ErrorCodes.Validation, - $"An item can have at most {MaxImagesPerItem} images.", 422); + // Fail fast on a full item before writing the file; re-checked inside the transaction below. + await EnsureRoomAsync(itemId, ct); var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(content, fileName, contentType, ct); - var image = new ItemImage + ItemImage image; + try { - ItemId = itemId, - OriginalFileName = Path.GetFileName(fileName), - StoredFileName = storedFileName, - RelativePath = relativePath, - ContentType = expectedContentType, - SizeBytes = sizeBytes, - SortOrder = existing.Count == 0 ? 0 : existing.Max() + 1, - UploadedBy = actorUserId, - UploadedAt = DateTime.UtcNow - }; + image = await _uow.ExecuteInTransactionAsync(async token => + { + var sortOrders = await EnsureRoomAsync(itemId, token); + var entity = new ItemImage + { + ItemId = itemId, + OriginalFileName = Path.GetFileName(fileName), + StoredFileName = storedFileName, + RelativePath = relativePath, + ContentType = expectedContentType, + SizeBytes = sizeBytes, + SortOrder = sortOrders.Count == 0 ? 0 : sortOrders.Max() + 1, + UploadedBy = actorUserId, + UploadedAt = DateTime.UtcNow + }; + await _images.AddAsync(entity, token); + return entity; + }, ct); + } + catch + { + // The row never landed, so the file just written would be an orphan — remove it (best effort, + // so a cleanup failure never hides the real error), then rethrow. + try { await _storage.DeleteAsync(relativePath, CancellationToken.None); } catch { /* left for manual cleanup */ } + throw; + } - await _images.AddAsync(image, ct); - await _uow.SaveChangesAsync(ct); - - return new ItemImageDto(image.ItemImageId, image.ItemId, image.OriginalFileName, image.ContentType, - image.SizeBytes, image.SortOrder, image.UploadedAt); + return Map(image); } public async Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default) { - var image = await _images.Query().AsNoTracking() - .FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct) + var image = await _images.GetForItemAsync(itemId, itemImageId, tracking: false, ct) ?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}."); return (await _storage.OpenReadAsync(image.RelativePath, ct), image.ContentType); @@ -113,19 +115,35 @@ public sealed class ItemImageService : IItemImageService public async Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default) { - var image = await _images.Query() - .FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct) + var image = await _images.GetForItemAsync(itemId, itemImageId, tracking: true, ct) ?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}."); - _images.Remove(image); - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + _images.Remove(image); + return Task.CompletedTask; + }, ct); + // File removed only after the row is gone, so a failed save never orphans a row. await _storage.DeleteAsync(image.RelativePath, ct); } private async Task EnsureItemExistsAsync(int itemId, CancellationToken ct) { - if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct)) + if (!await _images.ItemExistsAsync(itemId, ct)) throw new NotFoundException($"Item {itemId} was not found."); } + + /// The item's current sort orders; throws when it already has the maximum number of images. + private async Task> EnsureRoomAsync(int itemId, CancellationToken ct) + { + var sortOrders = await _images.ListSortOrdersAsync(itemId, ct); + if (sortOrders.Count >= MaxImagesPerItem) + throw new DomainException(ErrorCodes.Validation, + $"An item can have at most {MaxImagesPerItem} images.", 422); + return sortOrders; + } + + private static ItemImageDto Map(ItemImage i) => new( + i.ItemImageId, i.ItemId, i.OriginalFileName, i.ContentType, i.SizeBytes, i.SortOrder, i.UploadedAt); } diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index 7e043d8..8b58d13 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -9,7 +9,7 @@ using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IItemRepository namespace ERPCore.Services; @@ -23,101 +23,45 @@ namespace ERPCore.Services; /// e.g. "BL-100-0003"); this service only checks that it is unique. Items hold no /// item-type reference at all — see docs/10 Part C.9. /// +/// Queries go through ; every write runs inside +/// like . /// public sealed class ItemService : IItemService { - private readonly IRepository _items; - private readonly IRepository _categories; - private readonly IRepository _subCategories; - private readonly IRepository _brands; - private readonly IRepository _uoms; - private readonly IRepository _taxRates; - private readonly IRepository _approvalConfig; - - /// Price approvals on (the default) unless Settings → Approvals set price levels to 0. - private async Task PricesNeedApprovalAsync(CancellationToken ct) - => (await _approvalConfig.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId) - .Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0; - private readonly IRepository _vendors; - private readonly IRepository _warehouses; - private readonly IRepository _stockLayers; - private readonly IRepository _stockLedger; - private readonly IRepository _grnLines; - private readonly IRepository _batches; - private readonly IRepository _serials; + private readonly IItemRepository _items; private readonly IProductConfigService _config; private readonly INotificationService _notifications; private readonly IUnitOfWork _uow; - public ItemService( - IRepository items, - IRepository categories, - IRepository subCategories, - IRepository brands, - IRepository uoms, - IRepository taxRates, - IRepository vendors, - IRepository warehouses, - IRepository stockLayers, - IRepository stockLedger, - IRepository grnLines, - IRepository batches, - IRepository serials, - IProductConfigService config, - INotificationService notifications, - IUnitOfWork uow, - IRepository approvalConfig) + public ItemService(IItemRepository items, IProductConfigService config, INotificationService notifications, IUnitOfWork uow) { - _approvalConfig = approvalConfig; _items = items; - _categories = categories; - _subCategories = subCategories; - _brands = brands; - _uoms = uoms; - _taxRates = taxRates; - _vendors = vendors; - _warehouses = warehouses; - _stockLayers = stockLayers; - _stockLedger = stockLedger; - _grnLines = grnLines; - _batches = batches; - _serials = serials; _config = config; _notifications = notifications; _uow = uow; } + /// Price approvals on (the default) unless Settings → Approvals set price levels to 0. + private async Task PricesNeedApprovalAsync(CancellationToken ct) + => (await _items.GetPriceAdjustmentLevelsAsync(ct) ?? 2) > 0; + public async Task> ListAsync( PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, TrackingMode? trackingMode, CancellationToken ct = default) { - var q = _items.Query().AsNoTracking(); + var criteria = new ItemListCriteria(query.Q, status, categoryId, subCategoryId, brandId, trackingMode); + var (rows, total) = await _items.ListPagedAsync(criteria, query.Skip, query.PageSize, ct); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(i => EF.Functions.Like(i.Sku, $"%{term}%") || EF.Functions.Like(i.Name, $"%{term}%")); - } - if (status is not null) q = q.Where(i => i.Status == status); - if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId); - if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId); - if (brandId is not null) q = q.Where(i => i.BrandId == brandId); - if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode); + var items = rows.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.TaxRateId, i.TaxRate?.Name, i.TaxRate?.Percentage, + i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice, + i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit, + i.Status, i.Barcode)).ToList(); - var total = await q.CountAsync(ct); - var rows = await q.OrderBy(i => i.Sku) - .Skip(query.Skip).Take(query.PageSize) - .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.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.Barcode)) - .ToListAsync(ct); - - return PagedResponse.Create(rows, query.Page, query.PageSize, total); + return PagedResponse.Create(items, query.Page, query.PageSize, total); } public async Task> GetOverviewAsync( @@ -125,89 +69,38 @@ public sealed class ItemService : IItemService TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock, CancellationToken ct = default) { - var q = _items.Query().AsNoTracking(); - - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(i => EF.Functions.Like(i.Sku, $"%{term}%") || EF.Functions.Like(i.Name, $"%{term}%")); - } - if (status is not null) q = q.Where(i => i.Status == status); - if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId); - if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId); - if (brandId is not null) q = q.Where(i => i.BrandId == brandId); - if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode); - if (stockNature is not null) q = q.Where(i => i.StockNature == stockNature); - // Stock-state filters run as subqueries before paging, so totals and pages stay correct. - var layers = _stockLayers.Query(); - switch (stock) - { - case ItemStockFilter.InStock: - q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0); - break; - case ItemStockFilter.OutOfStock: - q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0); - break; - case ItemStockFilter.LowStock: - q = q.Where(i => i.ReorderSettings.Any(rs => - (layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId) - .Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint)); - break; - } - - var total = await q.CountAsync(ct); - var page = await q.OrderBy(i => i.Sku) - .Skip(query.Skip).Take(query.PageSize) - .Select(i => new { i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status, i.ReorderSettings }) - .ToListAsync(ct); + var criteria = new ItemListCriteria(query.Q, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock); + var (page, total) = await _items.ListOverviewPagedAsync(criteria, query.Skip, query.PageSize, ct); var itemIds = page.Select(i => i.ItemId).ToList(); // Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse. - var stockByItem = await _stockLayers.Query().AsNoTracking() - .Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0) - .GroupBy(l => l.ItemId) - .Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) }) - .ToDictionaryAsync(x => x.ItemId, ct); + var stockByItem = await _items.GetStockByItemAsync(itemIds, ct); // On-hand per (item, warehouse), to compare against each item's reorder settings. - var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking() - .Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0) - .GroupBy(l => new { l.ItemId, l.WarehouseId }) - .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) }) - .ToListAsync(ct)) - .ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty); + var onHandByItemWarehouse = await _items.GetOnHandByItemWarehouseAsync(itemIds, ct); // Last confirmed purchase (GRN) per item, with its vendor — pulled in bulk and // reduced in memory rather than a "first per group" query EF can't always translate. - var grnCandidates = await _grnLines.Query().AsNoTracking() - .Where(l => itemIds.Contains(l.ItemId) && l.Grn!.PostedAt != null) - .Select(l => new { l.ItemId, PostedAt = l.Grn!.PostedAt!.Value, l.Grn.VendorId, VendorName = l.Grn.Vendor!.Name }) - .ToListAsync(ct); - var lastPurchaseByItem = grnCandidates + var lastPurchaseByItem = (await _items.ListConfirmedPurchasesAsync(itemIds, ct)) .GroupBy(x => x.ItemId) .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.PostedAt).First()); // Qty sold in the trailing 30 days, across every sale channel (invoice/slip/bundle), // read off the stock ledger — the one place every sale channel posts uniformly. - var cutoff = DateTime.UtcNow.AddDays(-30); - var salesTypes = new[] { DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale }; - var salesByItem = await _stockLedger.Query().AsNoTracking() - .Where(l => itemIds.Contains(l.ItemId) && l.Direction == Direction.Out - && l.CreatedAt >= cutoff && salesTypes.Contains(l.SourceDocType)) - .GroupBy(l => l.ItemId) - .Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) }) - .ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct); + var salesByItem = await _items.GetQtyOutSinceAsync( + itemIds, DateTime.UtcNow.AddDays(-30), + [DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale], ct); var rows = page.Select(i => { - var stock = stockByItem.GetValueOrDefault(i.ItemId); + var hasStock = stockByItem.TryGetValue(i.ItemId, out var stockFigures); var isLowStock = i.ReorderSettings.Any(rs => onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint); var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId); return new ItemOverviewDto( i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status, - stock?.Qty ?? 0m, stock?.Value ?? 0m, isLowStock, + hasStock ? stockFigures.Qty : 0m, hasStock ? stockFigures.Value : 0m, isLowStock, lastPurchase?.PostedAt, lastPurchase?.VendorId, lastPurchase?.VendorName, salesByItem.GetValueOrDefault(i.ItemId)); }).ToList(); @@ -217,37 +110,25 @@ public sealed class ItemService : IItemService public async Task> GetPurchasePricesAsync(int itemId, int take = 5, CancellationToken ct = default) { - if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct)) + if (!await _items.ExistsAsync(itemId, ct)) throw new NotFoundException($"Item {itemId} was not found."); - return await _grnLines.Query().AsNoTracking() - .Where(l => l.ItemId == itemId && l.Grn!.PostedAt != null) - .OrderByDescending(l => l.Grn!.PostedAt).ThenByDescending(l => l.GrnLineId) - .Take(Math.Clamp(take, 1, 50)) - .Select(l => new ItemPurchasePriceDto( - l.GrnId, l.Grn!.DocNo, l.Grn.PostedAt!.Value, l.Grn.VendorId, l.Grn.Vendor!.Name, l.Grn.Warehouse!.Code, - l.Qty, l.UnitCost, l.DiscountPct, l.NetUnitCost, l.VatPct)) - .ToListAsync(ct); + return (await _items.ListPurchasePricesAsync(itemId, Math.Clamp(take, 1, 50), ct)) + .Select(p => new ItemPurchasePriceDto( + p.GrnId, p.GrnDocNo, p.PostedAt, p.VendorId, p.VendorName, p.WarehouseCode, + p.Qty, p.UnitCost, p.DiscountPct, p.NetUnitCost, p.VatPct)) + .ToList(); } public async Task?> GetAsync(int itemId, CancellationToken ct = default) { - var item = await _items.Query().AsNoTracking() - .Include(i => i.ReorderSettings) - .Include(i => i.TaxRate) - .FirstOrDefaultAsync(i => i.ItemId == itemId, ct); - + var item = await _items.GetWithDetailsAsync(itemId, tracking: false, ct); return item is null ? null : new ETagged(ToDetail(item), item.RowVersion); } public async Task> CreateAsync(CreateItemRequest request, CancellationToken ct = default) { - 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( @@ -259,37 +140,45 @@ public sealed class ItemService : IItemService var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit); var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null); - var item = new Item + var item = await _uow.ExecuteInTransactionAsync(async token => { - Sku = request.Sku.Trim(), - Name = request.Name.Trim(), - Description = request.Description, - CategoryId = request.CategoryId, - SubCategoryId = request.SubCategoryId, - BrandId = request.BrandId, - BaseUomId = request.BaseUomId, - DefaultVendorId = request.DefaultVendorId, - StockNature = request.StockNature, - TrackingMode = request.TrackingMode, - Warranty = request.Warranty, - WarrantyPeriodMonths = warrantyPeriodMonths, - TaxRateId = request.TaxRateId, - SellingMinPrice = request.SellingMinPrice, - SellingMaxPrice = request.SellingMaxPrice, - SellingFlatPrice = request.SellingFlatPrice, - ContentQty = request.ContentQty, - ContentUnit = request.ContentUnit, - ContentBaseQty = contentBaseQty, - ContentBaseUnit = contentBaseUnit, - Barcode = barcode, - Status = EntityStatus.Active, - CreatedAt = DateTime.UtcNow - }; + if (await _items.SkuExistsAsync(request.Sku, ct: token)) + throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400); + if (barcode is not null && await _items.BarcodeExistsAsync(barcode, ct: token)) + throw new DomainException(ErrorCodes.Validation, $"An item with barcode '{barcode}' already exists.", 422); - 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); + var entity = new Item + { + Sku = request.Sku.Trim(), + Name = request.Name.Trim(), + Description = request.Description, + CategoryId = request.CategoryId, + SubCategoryId = request.SubCategoryId, + BrandId = request.BrandId, + BaseUomId = request.BaseUomId, + DefaultVendorId = request.DefaultVendorId, + StockNature = request.StockNature, + TrackingMode = request.TrackingMode, + Warranty = request.Warranty, + WarrantyPeriodMonths = warrantyPeriodMonths, + TaxRateId = request.TaxRateId, + SellingMinPrice = request.SellingMinPrice, + SellingMaxPrice = request.SellingMaxPrice, + SellingFlatPrice = request.SellingFlatPrice, + ContentQty = request.ContentQty, + ContentUnit = request.ContentUnit, + ContentBaseQty = contentBaseQty, + ContentBaseUnit = contentBaseUnit, + Barcode = barcode, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + await _items.AddAsync(entity, token); + return entity; + }, ct); + + if (item.TaxRateId is int taxRateId) + item.TaxRate = await _items.GetTaxRateAsync(taxRateId, ct); await _notifications.RaiseAsync("item.created", "Item Created", $"Item {item.Sku} ({item.Name}) was created.", nameof(Item), item.ItemId, ct); @@ -299,23 +188,13 @@ public sealed class ItemService : IItemService public async Task> UpdateAsync( int itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default) { - var item = await _items.Query() - .Include(i => i.ReorderSettings) - .Include(i => i.TaxRate) - .FirstOrDefaultAsync(i => i.ItemId == itemId, ct) + var item = await _items.GetWithDetailsAsync(itemId, tracking: true, ct) ?? throw new NotFoundException($"Item {itemId} was not found."); if (item.RowVersion != expectedRowVersion) throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412); - if (!string.Equals(item.Sku, request.Sku, StringComparison.Ordinal) - && 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( @@ -327,7 +206,7 @@ public sealed class ItemService : IItemService // stock layers, ledger rows and document lines are all plain counts of it. Once any // of that history exists, changing it would silently reinterpret every one of those // numbers (240 bottles becoming 240 cases), so it is frozen instead. - if (item.BaseUomId != request.BaseUomId && await HasStockHistoryAsync(itemId, ct)) + if (item.BaseUomId != request.BaseUomId && await _items.HasStockHistoryAsync(itemId, ct)) throw new DomainException( ErrorCodes.MasterInUse, $"Item {itemId} has stock history; its base UOM can no longer be changed.", 409); @@ -346,33 +225,47 @@ public sealed class ItemService : IItemService var warrantyPeriodMonths = ValidateWarrantyPeriod( request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths); - item.Sku = request.Sku.Trim(); - item.Name = request.Name.Trim(); - item.Description = request.Description; - item.CategoryId = request.CategoryId; - item.SubCategoryId = request.SubCategoryId; - item.BrandId = request.BrandId; - item.BaseUomId = request.BaseUomId; - item.DefaultVendorId = request.DefaultVendorId; - item.StockNature = request.StockNature; - item.TrackingMode = request.TrackingMode; - item.Warranty = request.Warranty; - item.WarrantyPeriodMonths = warrantyPeriodMonths; - item.TaxRateId = request.TaxRateId; - item.SellingMinPrice = request.SellingMinPrice; - item.SellingMaxPrice = request.SellingMaxPrice; - item.SellingFlatPrice = request.SellingFlatPrice; - item.ContentQty = request.ContentQty; - item.ContentUnit = request.ContentUnit; - item.ContentBaseQty = contentBaseQty; - item.ContentBaseUnit = contentBaseUnit; - item.Barcode = barcode; - item.UpdatedAt = DateTime.UtcNow; + try + { + await _uow.ExecuteInTransactionAsync(async token => + { + if (!string.Equals(item.Sku, request.Sku, StringComparison.Ordinal) + && await _items.SkuExistsAsync(request.Sku, itemId, token)) + throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400); + if (barcode is not null && !string.Equals(item.Barcode, barcode, StringComparison.Ordinal) + && await _items.BarcodeExistsAsync(barcode, itemId, token)) + throw new DomainException(ErrorCodes.Validation, $"An item with barcode '{barcode}' already exists.", 422); - await SaveGuardingConcurrencyAsync(ct); - item.TaxRate = item.TaxRateId is null - ? null - : await _taxRates.Query().AsNoTracking().FirstAsync(t => t.TaxRateId == item.TaxRateId, ct); + item.Sku = request.Sku.Trim(); + item.Name = request.Name.Trim(); + item.Description = request.Description; + item.CategoryId = request.CategoryId; + item.SubCategoryId = request.SubCategoryId; + item.BrandId = request.BrandId; + item.BaseUomId = request.BaseUomId; + item.DefaultVendorId = request.DefaultVendorId; + item.StockNature = request.StockNature; + item.TrackingMode = request.TrackingMode; + item.Warranty = request.Warranty; + item.WarrantyPeriodMonths = warrantyPeriodMonths; + item.TaxRateId = request.TaxRateId; + item.SellingMinPrice = request.SellingMinPrice; + item.SellingMaxPrice = request.SellingMaxPrice; + item.SellingFlatPrice = request.SellingFlatPrice; + item.ContentQty = request.ContentQty; + item.ContentUnit = request.ContentUnit; + item.ContentBaseQty = contentBaseQty; + item.ContentBaseUnit = contentBaseUnit; + item.Barcode = barcode; + item.UpdatedAt = DateTime.UtcNow; + }, ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412); + } + + item.TaxRate = item.TaxRateId is int taxRateId ? await _items.GetTaxRateAsync(taxRateId, ct) : null; await _notifications.RaiseAsync("item.updated", "Item Updated", $"Item {item.Sku} ({item.Name}) was updated.", nameof(Item), item.ItemId, ct); return new ETagged(ToDetail(item), item.RowVersion); } @@ -382,9 +275,12 @@ public sealed class ItemService : IItemService var item = await _items.GetByIdAsync(itemId, ct) ?? throw new NotFoundException($"Item {itemId} was not found."); - item.Status = status; - item.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + item.Status = status; + item.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); } public async Task UpdateReorderAsync( @@ -393,40 +289,41 @@ public sealed class ItemService : IItemService if (request.Settings.Select(s => s.WarehouseId).Distinct().Count() != request.Settings.Count) throw new DomainException(ErrorCodes.Validation, "Duplicate warehouseId in reorder settings.", 400); - var item = await _items.Query() - .Include(i => i.ReorderSettings) - .FirstOrDefaultAsync(i => i.ItemId == itemId, ct) + var item = await _items.GetWithDetailsAsync(itemId, tracking: true, ct) ?? throw new NotFoundException($"Item {itemId} was not found."); foreach (var warehouseId in request.Settings.Select(s => s.WarehouseId)) - if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct)) + if (!await _items.WarehouseExistsAsync(warehouseId, ct)) throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422); - // Full-replacement upsert (avoids delete+insert clashes on the unique index). - foreach (var stale in item.ReorderSettings.Where(r => request.Settings.All(s => s.WarehouseId != r.WarehouseId)).ToList()) - item.ReorderSettings.Remove(stale); - foreach (var input in request.Settings) + await _uow.ExecuteInTransactionAsync(_ => { - var existing = item.ReorderSettings.FirstOrDefault(r => r.WarehouseId == input.WarehouseId); - if (existing is null) + // Full-replacement upsert (avoids delete+insert clashes on the unique index). + foreach (var stale in item.ReorderSettings.Where(r => request.Settings.All(s => s.WarehouseId != r.WarehouseId)).ToList()) + item.ReorderSettings.Remove(stale); + foreach (var input in request.Settings) { - item.ReorderSettings.Add(new ItemReorder + var existing = item.ReorderSettings.FirstOrDefault(r => r.WarehouseId == input.WarehouseId); + if (existing is null) { - ItemId = itemId, - WarehouseId = input.WarehouseId, - ReorderPoint = input.ReorderPoint, - ReorderQty = input.ReorderQty - }); + item.ReorderSettings.Add(new ItemReorder + { + ItemId = itemId, + WarehouseId = input.WarehouseId, + ReorderPoint = input.ReorderPoint, + ReorderQty = input.ReorderQty + }); + } + else + { + existing.ReorderPoint = input.ReorderPoint; + existing.ReorderQty = input.ReorderQty; + } } - else - { - existing.ReorderPoint = input.ReorderPoint; - existing.ReorderQty = input.ReorderQty; - } - } - item.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + item.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); var settings = item.ReorderSettings .OrderBy(r => r.WarehouseId) @@ -435,15 +332,6 @@ public sealed class ItemService : IItemService return new ItemReorderSettingsDto(settings); } - /// - /// Whether anything has ever been recorded against this item's stock. Checks the ledger - /// as well as live layers, because a fully consumed item has no layer left but its - /// history still reads in the old unit. - /// - private async Task HasStockHistoryAsync(int itemId, CancellationToken ct) - => await _stockLayers.Query().AnyAsync(l => l.ItemId == itemId, ct) - || await _stockLedger.Query().AnyAsync(l => l.ItemId == itemId, ct); - /// /// Validates every FK on an item write, and gates the optional ones on the product /// configuration (FR-MD-11). Note there is no item-type check: nothing on an item @@ -454,20 +342,18 @@ public sealed class ItemService : IItemService 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)) + if (!await _items.CategoryExistsAsync(categoryId, ct)) throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422); - if (subCategoryId is not null) + if (subCategoryId is int subId) { if (!config.SubcategoriesEnabled) throw new DomainException( ErrorCodes.ConfigDisabled, "Subcategories are disabled in the product configuration; subCategoryId must be null.", 422); - var sub = await _subCategories.Query().AsNoTracking() - .FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct); - if (sub is null) - throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422); + var sub = await _items.GetSubCategoryAsync(subId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422); if (sub.Status != EntityStatus.Active) throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422); // The two FKs must agree, or the item would claim a category its subcategory @@ -478,40 +364,34 @@ public sealed class ItemService : IItemService $"Subcategory {subCategoryId} belongs to category {sub.CategoryId}, not {categoryId}.", 422); } - if (brandId is not null) + if (brandId is int bId) { if (!config.BrandsEnabled) throw new DomainException( ErrorCodes.ConfigDisabled, "Brands are disabled in the product configuration; brandId must be null.", 422); - var brand = await _brands.Query().AsNoTracking() - .FirstOrDefaultAsync(b => b.BrandId == brandId, ct); - if (brand is null) - throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422); + var brand = await _items.GetBrandAsync(bId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422); if (brand.Status != EntityStatus.Active) throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} is inactive.", 422); } - if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct)) + if (!await _items.UomExistsAsync(baseUomId, ct)) throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422); - if (defaultVendorId is not null) + if (defaultVendorId is int vId) { - var vendor = await _vendors.Query().AsNoTracking() - .FirstOrDefaultAsync(v => v.VendorId == defaultVendorId, ct); - if (vendor is null) - throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} does not exist.", 422); + var vendor = await _items.GetVendorAsync(vId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} does not exist.", 422); if (vendor.Status != EntityStatus.Active) throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} is inactive.", 422); } - if (taxRateId is not null) + if (taxRateId is int tId) { - 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); + var taxRate = await _items.GetTaxRateAsync(tId, ct) + ?? 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); } @@ -610,18 +490,6 @@ public sealed class ItemService : IItemService return warrantyPeriodMonths; } - private async Task SaveGuardingConcurrencyAsync(CancellationToken ct) - { - try - { - await _uow.SaveChangesAsync(ct); - } - catch (DbUpdateConcurrencyException) - { - throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412); - } - } - /// Trims and blank-collapses a barcode, mirroring Sku.Trim() — 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. @@ -639,25 +507,20 @@ public sealed class ItemService : IItemService 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); + var batchForItem = await _items.FindBatchByCodeAsync(code, itemId, 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); + var serialForItem = await _items.FindSerialByCodeAsync(code, itemId, ct); if (serialForItem is not null) return ToScanResult(serialForItem); } - var item = await _items.Query().AsNoTracking() - .FirstOrDefaultAsync(i => i.Barcode == code || i.Sku == code, ct); + var item = await _items.FindItemByCodeAsync(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); + var serial = await _items.FindSerialByCodeAsync(code, ct: 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); + var batch = await _items.FindBatchByCodeAsync(code, ct: ct); if (batch is not null) return ToScanResult(batch); return null; diff --git a/Backend/ERPCore/Services/ItemTypeService.cs b/Backend/ERPCore/Services/ItemTypeService.cs index 3672423..ac4711c 100644 --- a/Backend/ERPCore/Services/ItemTypeService.cs +++ b/Backend/ERPCore/Services/ItemTypeService.cs @@ -7,7 +7,7 @@ using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IItemTypeRepository namespace ERPCore.Services; @@ -15,14 +15,15 @@ namespace ERPCore.Services; /// Item type master service (FR-MD-10) — maintains the Color/Size/Material list that /// GET /item-types serves to the frontend builder's dropdown. Nothing references /// these rows, so there is no in-use check to make and no cascade to worry about -/// (docs/11-BACKEND-PHASE1.md §2.7, docs/10 Part C.9). +/// (docs/11-BACKEND-PHASE1.md §2.7, docs/10 Part C.9). Queries go through ; +/// writes run inside like . /// public sealed class ItemTypeService : IItemTypeService { - private readonly IRepository _itemTypes; + private readonly IItemTypeRepository _itemTypes; private readonly IUnitOfWork _uow; - public ItemTypeService(IRepository itemTypes, IUnitOfWork uow) + public ItemTypeService(IItemTypeRepository itemTypes, IUnitOfWork uow) { _itemTypes = itemTypes; _uow = uow; @@ -30,46 +31,35 @@ public sealed class ItemTypeService : IItemTypeService public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) { - var q = _itemTypes.Query().AsNoTracking(); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(t => EF.Functions.Like(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 ItemTypeDto(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt)) - .ToListAsync(ct); - - return PagedResponse.Create(rows, query.Page, query.PageSize, total); + var (rows, total) = await _itemTypes.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct); + return PagedResponse.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); } public async Task?> GetAsync(int itemTypeId, CancellationToken ct = default) { - var itemType = await _itemTypes.Query().AsNoTracking() - .FirstOrDefaultAsync(t => t.ItemTypeId == itemTypeId, ct); + var itemType = await _itemTypes.GetForReadAsync(itemTypeId, ct); return itemType is null ? null : new ETagged(Map(itemType), itemType.RowVersion); } public async Task> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default) { var name = request.Name.Trim(); - if (await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower(), ct)) - throw new ConflictException($"An item type named '{name}' already exists."); - var itemType = new ItemType + var itemType = await _uow.ExecuteInTransactionAsync(async token => { - Name = name, - IsMeasurable = request.IsMeasurable, - Status = EntityStatus.Active, - CreatedAt = DateTime.UtcNow - }; + if (await _itemTypes.NameExistsAsync(name, ct: token)) + throw new ConflictException($"An item type named '{name}' already exists."); - await _itemTypes.AddAsync(itemType, ct); - await _uow.SaveChangesAsync(ct); + var entity = new ItemType + { + Name = name, + IsMeasurable = request.IsMeasurable, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + await _itemTypes.AddAsync(entity, token); + return entity; + }, ct); return new ETagged(Map(itemType), itemType.RowVersion); } @@ -84,21 +74,23 @@ public sealed class ItemTypeService : IItemTypeService throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412); var name = request.Name.Trim(); - if (!string.Equals(itemType.Name, name, StringComparison.Ordinal) - && await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower() && t.ItemTypeId != itemTypeId, ct)) - throw new ConflictException($"An item type named '{name}' already exists."); - - // Renaming does not touch existing items: their SKUs already encode the values that - // were chosen, and nothing joins back to this row (docs/10 Part C.9). - itemType.Name = name; - // Omitted ⇒ keep what is stored. A plain bool would bind an absent property as false and - // so let a name-only PUT silently clear the flag on every rename. - itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable; - itemType.UpdatedAt = DateTime.UtcNow; try { - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(async token => + { + if (!string.Equals(itemType.Name, name, StringComparison.Ordinal) + && await _itemTypes.NameExistsAsync(name, itemTypeId, token)) + throw new ConflictException($"An item type named '{name}' already exists."); + + // Renaming does not touch existing items: their SKUs already encode the values that + // were chosen, and nothing joins back to this row (docs/10 Part C.9). + itemType.Name = name; + // Omitted ⇒ keep what is stored. A plain bool would bind an absent property as false and + // so let a name-only PUT silently clear the flag on every rename. + itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable; + itemType.UpdatedAt = DateTime.UtcNow; + }, ct); } catch (DbUpdateConcurrencyException) { @@ -113,9 +105,12 @@ public sealed class ItemTypeService : IItemTypeService var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct) ?? throw new NotFoundException($"Item type {itemTypeId} was not found."); - itemType.Status = status; - itemType.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + itemType.Status = status; + itemType.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); } private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt); diff --git a/Backend/ERPCore/Services/NumberSequenceService.cs b/Backend/ERPCore/Services/NumberSequenceService.cs index 055abd4..4e2dd42 100644 --- a/Backend/ERPCore/Services/NumberSequenceService.cs +++ b/Backend/ERPCore/Services/NumberSequenceService.cs @@ -1,48 +1,28 @@ -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; namespace ERPCore.Services; /// -/// Atomic document-number issuer. Uses a single INSERT … ON DUPLICATE KEY UPDATE … -/// RETURNING so concurrent issues for the same (docType, branchId, year) cannot get -/// the same number (the upsert holds an exclusive lock on the row until commit). 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 SqlQuery is avoided here because it -/// wraps the statement in a subquery, which is not valid for a 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. +/// Atomic document-number issuer (FR-X-03). The counter bump is one atomic upsert in +/// , enlisted in the caller's current transaction so the +/// number commits or rolls back with the document. 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. /// PO-BR-001-2026-00042. /// /// -/// MariaDB 10.5+ supports RETURNING on an upsert, so this stays a single atomic -/// statement exactly as the PostgreSQL version was — which is what keeps the callers that -/// run outside a UoW transaction safe. Do NOT "optimise" this into the one-statement -/// LAST_INSERT_ID() trick: this table has an AUTO_INCREMENT surrogate key, so on the -/// insert branch the engine's own auto-increment value clobbers the explicit one and the -/// first document of a (docType, branch, year) would be numbered with the sequence table's -/// PK. Splitting it into INSERT-then-SELECT is also wrong here, because the non-transacted -/// callers would then race. Side note: the surrogate AUTO_INCREMENT burns a value on the -/// duplicate path, so sequence_id has gaps — harmless, do not "fix" it. -/// ON DUPLICATE KEY UPDATE fires on any unique-key collision; today the only unique -/// keys are the never-supplied PK and IX(doc_type, branch_id, year), so it is unambiguous. -/// Adding another unique index to this table would silently change which row gets updated. +/// Unlike the other services this one deliberately does NOT open its own +/// IUnitOfWork.ExecuteInTransactionAsync: it is called from inside other services' transactions +/// (bank deposits, GRNs, expenses, …), so it joins whatever transaction is current instead of starting one. /// public sealed class NumberSequenceService : INumberSequenceService { - private readonly ErpDbContext _db; - private readonly IRepository _branches; + private readonly INumberSequenceRepository _sequences; private readonly IBranchConfigService _branchConfig; - public NumberSequenceService(ErpDbContext db, IRepository branches, IBranchConfigService branchConfig) + public NumberSequenceService(INumberSequenceRepository sequences, IBranchConfigService branchConfig) { - _db = db; - _branches = branches; + _sequences = sequences; _branchConfig = branchConfig; } @@ -56,38 +36,10 @@ public sealed class NumberSequenceService : INumberSequenceService { var year = DateTime.UtcNow.Year; - var branchCode = await _branches.Query().AsNoTracking() - .Where(b => b.BranchId == branchId).Select(b => b.Code).FirstOrDefaultAsync(ct) + var branchCode = await _sequences.GetBranchCodeAsync(branchId, 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); - - await using var cmd = conn.CreateCommand(); - cmd.Transaction = _db.Database.CurrentTransaction?.GetDbTransaction(); - cmd.CommandText = """ - INSERT INTO `number_sequences` (`doc_type`, `branch_id`, `year`, `last_number`) - VALUES (@docType, @branchId, @year, 1) - ON DUPLICATE KEY UPDATE `last_number` = `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); - + var next = await _sequences.IncrementAsync(docType, branchId, year, ct); return $"{docType}-{branchCode}-{year}-{next:D5}"; } - - private static void AddParam(IDbCommand cmd, string name, object value) - { - var p = cmd.CreateParameter(); - p.ParameterName = name; - p.Value = value; - cmd.Parameters.Add(p); - } } diff --git a/Backend/ERPCore/Services/OpeningStockService.cs b/Backend/ERPCore/Services/OpeningStockService.cs index 6280252..b29cf76 100644 --- a/Backend/ERPCore/Services/OpeningStockService.cs +++ b/Backend/ERPCore/Services/OpeningStockService.cs @@ -8,7 +8,6 @@ using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; using ERPCore.System.Time; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -30,6 +29,8 @@ namespace ERPCore.Services; /// check; this is an admin-only, rare operation and is not serialised. /// /// Untracked items only: serial- and batch-tracked items need per-unit identities, which a plain quantity cannot give. +/// Queries and direct row writes go through ; posting runs inside +/// like . /// public sealed class OpeningStockService : IOpeningStockService { @@ -38,11 +39,7 @@ public sealed class OpeningStockService : IOpeningStockService private const string SourceModule = "OPENING"; private const string JournalType = "OPENING_BALANCE"; - private readonly IRepository _runs; - private readonly IRepository _items; - private readonly IRepository _warehouses; - private readonly IRepository _ledger; - private readonly IRepository _journal; + private readonly IOpeningStockRepository _runs; private readonly IFifoCostingService _fifo; private readonly IGeneralLedgerService _gl; private readonly IGlAccountConfigService _glAccounts; @@ -50,15 +47,10 @@ public sealed class OpeningStockService : IOpeningStockService private readonly IUnitOfWork _uow; public OpeningStockService( - IRepository runs, IRepository items, IRepository warehouses, - IRepository ledger, IRepository journal, IFifoCostingService fifo, + IOpeningStockRepository runs, IFifoCostingService fifo, IGeneralLedgerService gl, IGlAccountConfigService glAccounts, ICurrentUser currentUser, IUnitOfWork uow) { _runs = runs; - _items = items; - _warehouses = warehouses; - _ledger = ledger; - _journal = journal; _fifo = fifo; _gl = gl; _glAccounts = glAccounts; @@ -68,25 +60,15 @@ public sealed class OpeningStockService : IOpeningStockService public async Task> ListAsync(CancellationToken ct = default) { - var rows = await _runs.Query().AsNoTracking() - .Where(r => r.Kind == OpeningBalanceKind.Stock) - .OrderByDescending(r => r.OpeningBalanceRunId) - .Select(r => new - { - r.OpeningBalanceRunId, r.CutoverDate, r.GlJournalNo, r.TotalAmount, r.PostedAt, r.PostedBy, - LineCount = r.StockLines.Count - }) - .ToListAsync(ct); - return rows.Select(r => new OpeningStockRunSummaryDto( - r.OpeningBalanceRunId, r.CutoverDate, r.GlJournalNo, r.TotalAmount, r.LineCount, r.PostedAt, r.PostedBy)).ToList(); + var rows = await _runs.ListStockRunsAsync(ct); + return rows.Select(x => new OpeningStockRunSummaryDto( + x.Run.OpeningBalanceRunId, x.Run.CutoverDate, x.Run.GlJournalNo, x.Run.TotalAmount, x.LineCount, + x.Run.PostedAt, x.Run.PostedBy)).ToList(); } public async Task GetAsync(int runId, CancellationToken ct = default) { - var run = await _runs.Query().AsNoTracking() - .Include(r => r.StockLines).ThenInclude(l => l.Item) - .Include(r => r.StockLines).ThenInclude(l => l.Warehouse) - .FirstOrDefaultAsync(r => r.OpeningBalanceRunId == runId && r.Kind == OpeningBalanceKind.Stock, ct); + var run = await _runs.GetStockRunAsync(runId, ct); return run is null ? null : Map(run); } @@ -99,8 +81,8 @@ public sealed class OpeningStockService : IOpeningStockService // ---- validate every line before anything is written var itemIds = request.Lines.Select(l => l.ItemId).Distinct().ToList(); var warehouseIds = request.Lines.Select(l => l.WarehouseId).Distinct().ToList(); - var items = await _items.Query().AsNoTracking().Where(i => itemIds.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct); - var warehouses = await _warehouses.Query().AsNoTracking().Where(w => warehouseIds.Contains(w.WarehouseId)).ToDictionaryAsync(w => w.WarehouseId, ct); + var items = await _runs.GetItemsAsync(itemIds, ct); + var warehouses = await _runs.GetWarehousesAsync(warehouseIds, ct); var seen = new HashSet<(int, int)>(); foreach (var line in request.Lines) @@ -125,11 +107,7 @@ public sealed class OpeningStockService : IOpeningStockService } // ---- an item/warehouse that already has movements is not "opening" any more - var existing = await _ledger.Query().AsNoTracking() - .Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId)) - .Select(l => new { l.ItemId, l.WarehouseId }) - .Distinct() - .ToListAsync(ct); + var existing = await _runs.ListMovedPairsAsync(itemIds, warehouseIds, ct); var clashes = existing.Where(e => seen.Contains((e.ItemId, e.WarehouseId))).ToList(); if (clashes.Count > 0) { @@ -169,7 +147,7 @@ public sealed class OpeningStockService : IOpeningStockService total += value; await _fifo.CreateInboundLayerAsync(line.ItemId, line.WarehouseId, null, null, null, line.Qty, line.UnitCost, receiptDate, token); - await _ledger.AddAsync(new StockLedger + await _runs.AddLedgerAsync(new StockLedger { ItemId = line.ItemId, WarehouseId = line.WarehouseId, @@ -183,7 +161,7 @@ public sealed class OpeningStockService : IOpeningStockService SourceDocId = entity.OpeningBalanceRunId, CreatedAt = receiptDate }, token); - await _journal.AddAsync(new JournalEntryStub + await _runs.AddJournalStubAsync(new JournalEntryStub { SourceDocType = SourceDocType, SourceDocId = entity.OpeningBalanceRunId, diff --git a/Backend/ERPCore/Services/PriceAdjustmentService.cs b/Backend/ERPCore/Services/PriceAdjustmentService.cs index 7837e8d..228d31b 100644 --- a/Backend/ERPCore/Services/PriceAdjustmentService.cs +++ b/Backend/ERPCore/Services/PriceAdjustmentService.cs @@ -8,7 +8,7 @@ using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IPriceAdjustmentRepository namespace ERPCore.Services; @@ -18,18 +18,15 @@ namespace ERPCore.Services; /// ). Approval then writes the new prices to the items and records an /// row — unless an item was repriced since the request was made, in /// which case the approval is refused as stale. One pending request per item at a time. +/// Queries go through ; every write runs inside +/// like . /// public sealed class PriceAdjustmentService : IPriceAdjustmentService { public const string ApprovalDocType = "PADJ"; public const string ApprovalNavCode = "products.price-adjustments"; - private readonly IRepository _adjustments; - private readonly IRepository _lines; - private readonly IRepository _items; - private readonly IRepository _history; - private readonly IRepository _users; - private readonly IRepository _config; + private readonly IPriceAdjustmentRepository _adjustments; private readonly IApprovalService _approvals; private readonly INotificationService _notifications; private readonly INumberSequenceService _numbers; @@ -37,17 +34,10 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService private readonly IUnitOfWork _uow; public PriceAdjustmentService( - IRepository adjustments, IRepository lines, IRepository items, - IRepository history, IRepository users, IRepository config, - IApprovalService approvals, INotificationService notifications, INumberSequenceService numbers, - ICurrentUser currentUser, IUnitOfWork uow) + IPriceAdjustmentRepository adjustments, IApprovalService approvals, INotificationService notifications, + INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) { _adjustments = adjustments; - _lines = lines; - _items = items; - _history = history; - _users = users; - _config = config; _approvals = approvals; _notifications = notifications; _numbers = numbers; @@ -57,29 +47,16 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService public async Task> ListAsync(PageQuery query, PriceAdjustmentStatus? status, int? itemId, CancellationToken ct = default) { - var q = _adjustments.Query().AsNoTracking(); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || EF.Functions.Like(p.Reason, $"%{term}%")); - } - if (status is not null) q = q.Where(p => p.Status == status); - if (itemId is not null) q = q.Where(p => p.Lines.Any(l => l.ItemId == itemId)); - - var total = await q.CountAsync(ct); - var rows = await q.OrderByDescending(p => p.PriceAdjustmentId) - .Skip(query.Skip).Take(query.PageSize) - .Select(p => new PriceAdjustmentSummaryDto( - p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals, - p.Status == PriceAdjustmentStatus.PendingApproval ? 1 : p.Status == PriceAdjustmentStatus.PendingSecondApproval ? 2 : null, - p.Lines.Count, p.CreatedBy, p.Creator!.DisplayName, p.CreatedAt, p.AppliedAt)) - .ToListAsync(ct); - return PagedResponse.Create(rows, query.Page, query.PageSize, total); + var (rows, total) = await _adjustments.ListPagedAsync(query.Q, status, itemId, query.Skip, query.PageSize, ct); + var items = rows.Select(p => new PriceAdjustmentSummaryDto( + p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals, PendingLevel(p.Status), + p.LineCount, p.CreatedBy, p.CreatorName, p.CreatedAt, p.AppliedAt)).ToList(); + return PagedResponse.Create(items, query.Page, query.PageSize, total); } public async Task GetAsync(int priceAdjustmentId, CancellationToken ct = default) { - var adjustment = await Load(tracking: false).FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct); + var adjustment = await _adjustments.GetWithDetailsAsync(priceAdjustmentId, ct); return adjustment is null ? null : await ToDtoAsync(adjustment, ct); } @@ -94,16 +71,8 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService throw new DomainException(ErrorCodes.Validation, "Each item can appear only once in a request.", 400); var ids = request.Lines.Select(l => l.ItemId).ToList(); - var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct); - // Queried from the line side (not PriceAdjustment.Lines via SelectMany) so this stays a - // plain join — a SelectMany over a to-many navigation generates a CROSS APPLY that - // Pomelo/MySQL can't translate (it works fine against PostgreSQL, which is what this - // predates — 2026-10-16 MySQL-port regression). - var pendingItemIds = await _lines.Query().AsNoTracking() - .Where(l => ids.Contains(l.ItemId) - && (l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingApproval || l.PriceAdjustment!.Status == PriceAdjustmentStatus.PendingSecondApproval)) - .Select(l => new { l.ItemId, DocNo = l.PriceAdjustment!.DocNo }) - .ToListAsync(ct); + var items = await _adjustments.GetItemsForUpdateAsync(ids, ct); + var pendingItemIds = await _adjustments.ListPendingRequestsForItemsAsync(ids, ct); var lines = new List(); var largestChange = 0m; @@ -112,7 +81,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService if (!items.TryGetValue(input.ItemId, out var item)) throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422); var clash = pendingItemIds.FirstOrDefault(x => x.ItemId == input.ItemId); - if (clash is not null) + if (clash.DocNo is not null) throw new DomainException(ErrorCodes.PriceAdjustmentItemPending, $"{item.Sku} already has a pending price change ({clash.DocNo}) — approve, reject or withdraw that first.", 409); ValidatePrices(item.Sku, input.NewMinPrice, input.NewMaxPrice, input.NewFlatPrice); @@ -184,8 +153,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService adjustment.Status = PriceAdjustmentStatus.PendingSecondApproval; return 0; } - var ids = adjustment.Lines.Select(l => l.ItemId).ToList(); - var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, token); + var items = await _adjustments.GetItemsForUpdateAsync(adjustment.Lines.Select(l => l.ItemId).ToList(), token); adjustment.Status = PriceAdjustmentStatus.Approved; await ApplyInCurrentTransactionAsync(adjustment, items, now, token); return 0; @@ -211,11 +179,13 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService var adjustment = await LoadPendingAsync(priceAdjustmentId, ct); await _approvals.DecideAsync(Target(adjustment), approve: false, reason, ct); - adjustment.Status = PriceAdjustmentStatus.Rejected; - adjustment.RejectedBy = _currentUser.AuditUserId; - adjustment.RejectedAt = DateTime.UtcNow; - adjustment.RejectReason = reason.Trim(); - await SaveDecisionAsync(adjustment, ct); + await SaveDecisionAsync(adjustment, () => + { + adjustment.Status = PriceAdjustmentStatus.Rejected; + adjustment.RejectedBy = _currentUser.AuditUserId; + adjustment.RejectedAt = DateTime.UtcNow; + adjustment.RejectReason = reason.Trim(); + }, ct); await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.rejected", $"Rejected: {adjustment.DocNo}", $"Price adjustment {adjustment.DocNo} was rejected: {adjustment.RejectReason}", @@ -229,23 +199,22 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService if (adjustment.CreatedBy != _currentUser.AuditUserId) throw new DomainException(ErrorCodes.ApprovalForbidden, "Only the person who made the request can withdraw it.", 403); - adjustment.Status = PriceAdjustmentStatus.Cancelled; - adjustment.CancelledAt = DateTime.UtcNow; - await SaveDecisionAsync(adjustment, ct); + await SaveDecisionAsync(adjustment, () => + { + adjustment.Status = PriceAdjustmentStatus.Cancelled; + adjustment.CancelledAt = DateTime.UtcNow; + }, ct); return (await GetAsync(adjustment.PriceAdjustmentId, ct))!; } public async Task> ItemHistoryAsync(int itemId, CancellationToken ct = default) { - var rows = await _history.Query().AsNoTracking() - .Where(h => h.ItemId == itemId) - .OrderByDescending(h => h.AppliedAt).ThenByDescending(h => h.ItemPriceHistoryId) - .ToListAsync(ct); + var rows = await _adjustments.ListItemHistoryAsync(itemId, ct); var userIds = rows.SelectMany(r => new int?[] { r.RequestedBy, r.ApprovedLevel1By, r.ApprovedLevel2By }).OfType().Distinct().ToList(); - var names = await _users.Query().AsNoTracking().Where(u => userIds.Contains(u.UserId)).ToDictionaryAsync(u => u.UserId, u => u.DisplayName, ct); + var names = await _adjustments.GetUserNamesAsync(userIds, ct); var docIds = rows.Select(r => r.PriceAdjustmentId).OfType().Distinct().ToList(); - var docNos = await _adjustments.Query().AsNoTracking().Where(p => docIds.Contains(p.PriceAdjustmentId)).ToDictionaryAsync(p => p.PriceAdjustmentId, p => p.DocNo, ct); + var docNos = await _adjustments.GetDocNosAsync(docIds, ct); string? Name(int? id) => id is int v ? names.GetValueOrDefault(v) : null; return rows.Select(h => new ItemPriceHistoryDto( @@ -256,8 +225,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService } public async Task PricesNeedApprovalAsync(CancellationToken ct = default) - => (await _config.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId) - .Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0; + => (await _adjustments.GetPriceAdjustmentLevelsAsync(ct) ?? 2) > 0; // --- Helpers --------------------------------------------------------------------------------- @@ -277,7 +245,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService item.SellingFlatPrice = line.NewFlatPrice; item.UpdatedAt = now; - await _history.AddAsync(new ItemPriceHistory + await _adjustments.AddHistoryAsync(new ItemPriceHistory { ItemId = item.ItemId, PriceAdjustment = adjustment, // FK set by EF, also when the request is new @@ -310,27 +278,25 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService return Math.Abs(newPrice.Value - oldPrice.Value) / oldPrice.Value * 100m; } - private IQueryable Load(bool tracking) - { - var q = _adjustments.Query().Include(p => p.Lines).ThenInclude(l => l.Item).Include(p => p.Creator); - return tracking ? q : q.AsNoTracking(); - } - private async Task LoadPendingAsync(int priceAdjustmentId, CancellationToken ct) { - var adjustment = await _adjustments.Query().Include(p => p.Lines) - .FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct) + var adjustment = await _adjustments.GetForDecisionAsync(priceAdjustmentId, ct) ?? throw new NotFoundException($"Price adjustment {priceAdjustmentId} was not found."); if (adjustment.Status is not (PriceAdjustmentStatus.PendingApproval or PriceAdjustmentStatus.PendingSecondApproval)) throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} is {adjustment.Status} and not waiting for approval.", 409); return adjustment; } - private async Task SaveDecisionAsync(PriceAdjustment adjustment, CancellationToken ct) + /// Applies in a transaction; a lost race on the request becomes a 409. + private async Task SaveDecisionAsync(PriceAdjustment adjustment, Action decide, CancellationToken ct) { try { - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + decide(); + return Task.CompletedTask; + }, ct); } catch (DbUpdateConcurrencyException) { @@ -338,7 +304,9 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService } } - private static int? PendingLevel(PriceAdjustment p) => p.Status switch + private static int? PendingLevel(PriceAdjustment p) => PendingLevel(p.Status); + + private static int? PendingLevel(PriceAdjustmentStatus status) => status switch { PriceAdjustmentStatus.PendingApproval => 1, PriceAdjustmentStatus.PendingSecondApproval => 2, diff --git a/Backend/ERPCore/Services/ProcurementConfigService.cs b/Backend/ERPCore/Services/ProcurementConfigService.cs index 6b83ca6..d925534 100644 --- a/Backend/ERPCore/Services/ProcurementConfigService.cs +++ b/Backend/ERPCore/Services/ProcurementConfigService.cs @@ -8,18 +8,22 @@ using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IProcurementConfigRepository namespace ERPCore.Services; -/// Procurement configuration service over the singleton row seeded by DataSeeder. +/// +/// Procurement configuration service over the singleton row seeded by DataSeeder. Queries go through +/// ; the update runs inside +/// like . +/// public sealed class ProcurementConfigService : IProcurementConfigService { - private readonly IRepository _config; + private readonly IProcurementConfigRepository _config; private readonly ICurrentUser _currentUser; private readonly IUnitOfWork _uow; - public ProcurementConfigService(IRepository config, ICurrentUser currentUser, IUnitOfWork uow) + public ProcurementConfigService(IProcurementConfigRepository config, ICurrentUser currentUser, IUnitOfWork uow) { _config = config; _currentUser = currentUser; @@ -28,8 +32,7 @@ public sealed class ProcurementConfigService : IProcurementConfigService public async Task> GetAsync(CancellationToken ct = default) { - var config = await _config.Query().AsNoTracking() - .FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct) + var config = await _config.GetForReadAsync(ct) ?? throw new NotFoundException("Procurement configuration has not been seeded."); return new ETagged(Map(config), config.RowVersion); @@ -44,17 +47,19 @@ public sealed class ProcurementConfigService : IProcurementConfigService if (config.RowVersion != expectedRowVersion) throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); - config.VendorContactMode = request.VendorContactMode!.Value; - config.VendorAddressEnabled = request.VendorAddressEnabled!.Value; - config.PoTaxEnabled = request.PoTaxEnabled!.Value; - config.VendorPaymentTermsEnabled = request.VendorPaymentTermsEnabled!.Value; - config.VendorTaxRegEnabled = request.VendorTaxRegEnabled!.Value; - config.UpdatedAt = DateTime.UtcNow; - config.UpdatedBy = _currentUser.AuditUserId; - try { - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + config.VendorContactMode = request.VendorContactMode!.Value; + config.VendorAddressEnabled = request.VendorAddressEnabled!.Value; + config.PoTaxEnabled = request.PoTaxEnabled!.Value; + config.VendorPaymentTermsEnabled = request.VendorPaymentTermsEnabled!.Value; + config.VendorTaxRegEnabled = request.VendorTaxRegEnabled!.Value; + config.UpdatedAt = DateTime.UtcNow; + config.UpdatedBy = _currentUser.AuditUserId; + return Task.CompletedTask; + }, ct); } catch (DbUpdateConcurrencyException) { @@ -66,8 +71,7 @@ public sealed class ProcurementConfigService : IProcurementConfigService public async Task GetFlagsAsync(CancellationToken ct = default) { - var config = await _config.Query().AsNoTracking() - .FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct); + var config = await _config.GetForReadAsync(ct); // No row (not seeded yet): contact/address/tax off, the long-standing terms and tax-reg fields on. return config is null ? new ProcurementConfigDto(VendorContactMode.None, false, false, null, null, true, true) : Map(config); } diff --git a/Backend/ERPCore/Services/ProductConfigService.cs b/Backend/ERPCore/Services/ProductConfigService.cs index e9fce45..600f842 100644 --- a/Backend/ERPCore/Services/ProductConfigService.cs +++ b/Backend/ERPCore/Services/ProductConfigService.cs @@ -6,22 +6,24 @@ using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IProductConfigRepository namespace ERPCore.Services; /// /// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder. /// Reads are never gated - only writes consult the flags - so switching a feature off -/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8). +/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8). Queries go through +/// ; the update runs inside +/// like . /// public sealed class ProductConfigService : IProductConfigService { - private readonly IRepository _config; + private readonly IProductConfigRepository _config; private readonly ICurrentUser _currentUser; private readonly IUnitOfWork _uow; - public ProductConfigService(IRepository config, ICurrentUser currentUser, IUnitOfWork uow) + public ProductConfigService(IProductConfigRepository config, ICurrentUser currentUser, IUnitOfWork uow) { _config = config; _currentUser = currentUser; @@ -30,8 +32,7 @@ public sealed class ProductConfigService : IProductConfigService public async Task> GetAsync(CancellationToken ct = default) { - var config = await _config.Query().AsNoTracking() - .FirstOrDefaultAsync(c => c.ConfigId == ProductConfig.SingletonId, ct) + var config = await _config.GetForReadAsync(ct) ?? throw new NotFoundException("Product configuration has not been seeded."); return new ETagged(Map(config), config.RowVersion); @@ -46,34 +47,36 @@ public sealed class ProductConfigService : IProductConfigService if (config.RowVersion != expectedRowVersion) throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); - // Non-null by [Required] on the nullable bools - an omitted flag is a 400, so the - // model binder has already rejected anything that would reach here with a null. - config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value; - config.BrandsEnabled = request.BrandsEnabled!.Value; - config.ItemTypesEnabled = request.ItemTypesEnabled!.Value; - config.SellingPricesEnabled = request.SellingPricesEnabled!.Value; - config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value; - config.BatchListsEnabled = request.BatchListsEnabled!.Value; - config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value; - // Custom variants live in the item-variants section - switching product variants - // off switches this off too, so it can never be left on by itself. - config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value; - config.ItemImagesEnabled = request.ItemImagesEnabled!.Value; - // Custom warranty periods depend on warranty periods - switching the parent off - // switches this off too, so it can never be left on by itself. - config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value; - config.StockNatureEnabled = request.StockNatureEnabled!.Value; - config.TrackingModeEnabled = request.TrackingModeEnabled!.Value; - config.LowStockAlertsEnabled = request.LowStockAlertsEnabled!.Value; - // The warehouse picker lives in the low-stock alert section - switching alerts off - // switches this off too, so it can never be left on by itself. - config.AlertWarehouseEnabled = config.LowStockAlertsEnabled && request.AlertWarehouseEnabled!.Value; - config.UpdatedAt = DateTime.UtcNow; - config.UpdatedBy = _currentUser.AuditUserId; - try { - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + // Non-null by [Required] on the nullable bools - an omitted flag is a 400, so the + // model binder has already rejected anything that would reach here with a null. + config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value; + config.BrandsEnabled = request.BrandsEnabled!.Value; + config.ItemTypesEnabled = request.ItemTypesEnabled!.Value; + config.SellingPricesEnabled = request.SellingPricesEnabled!.Value; + config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value; + config.BatchListsEnabled = request.BatchListsEnabled!.Value; + config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value; + // Custom variants live in the item-variants section - switching product variants + // off switches this off too, so it can never be left on by itself. + config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value; + config.ItemImagesEnabled = request.ItemImagesEnabled!.Value; + // Custom warranty periods depend on warranty periods - switching the parent off + // switches this off too, so it can never be left on by itself. + config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value; + config.StockNatureEnabled = request.StockNatureEnabled!.Value; + config.TrackingModeEnabled = request.TrackingModeEnabled!.Value; + config.LowStockAlertsEnabled = request.LowStockAlertsEnabled!.Value; + // The warehouse picker lives in the low-stock alert section - switching alerts off + // switches this off too, so it can never be left on by itself. + config.AlertWarehouseEnabled = config.LowStockAlertsEnabled && request.AlertWarehouseEnabled!.Value; + config.UpdatedAt = DateTime.UtcNow; + config.UpdatedBy = _currentUser.AuditUserId; + return Task.CompletedTask; + }, ct); } catch (DbUpdateConcurrencyException) { diff --git a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs index b104526..cd0ac6f 100644 --- a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs +++ b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs @@ -8,7 +8,6 @@ using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; using ERPCore.System.Time; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -19,22 +18,22 @@ namespace ERPCore.Services; /// account) — the mirror image of (that one /// credits Accounts Receivable when the customer pays; this one debits Accounts Payable /// when the vendor is paid) — using the same call-GL-before-commit pattern so a -/// rejected/unreachable GL post rolls back the whole payment atomically. +/// rejected/unreachable GL post rolls back the whole payment atomically (inside +/// , like ). +/// Queries go through . /// public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentService { - private readonly IRepository _invoices; - private readonly IRepository _payments; + private readonly IPurchaseInvoicePaymentRepository _payments; private readonly IGeneralLedgerService _gl; private readonly ICurrentUser _currentUser; private readonly IUnitOfWork _uow; private readonly IGlAccountConfigService _glAccounts; public PurchaseInvoicePaymentService( - IRepository invoices, IRepository payments, IGeneralLedgerService gl, + IPurchaseInvoicePaymentRepository payments, IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts) { - _invoices = invoices; _payments = payments; _gl = gl; _currentUser = currentUser; @@ -44,7 +43,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi public async Task PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default) { - var invoice = await _invoices.Query().FirstOrDefaultAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct) + var invoice = await _payments.GetInvoiceForUpdateAsync(purchaseInvoiceId, ct) ?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found."); if (invoice.Status != PurchaseInvoiceStatus.Posted) @@ -120,16 +119,10 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi public async Task> ListAsync(int purchaseInvoiceId, CancellationToken ct = default) { - if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct)) + if (!await _payments.InvoiceExistsAsync(purchaseInvoiceId, ct)) throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found."); - return await _payments.Query().AsNoTracking() - .Where(p => p.PurchaseInvoiceId == purchaseInvoiceId) - .OrderByDescending(p => p.PurchaseInvoicePaymentId) - .Select(p => new PurchaseInvoicePaymentDto( - p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate, - p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt)) - .ToListAsync(ct); + return (await _payments.ListForInvoiceAsync(purchaseInvoiceId, ct)).Select(Map).ToList(); } private static PurchaseInvoicePaymentDto Map(PurchaseInvoicePayment p) => new( diff --git a/Backend/ERPCore/Services/PurchaseInvoiceService.cs b/Backend/ERPCore/Services/PurchaseInvoiceService.cs index 744f822..1637d97 100644 --- a/Backend/ERPCore/Services/PurchaseInvoiceService.cs +++ b/Backend/ERPCore/Services/PurchaseInvoiceService.cs @@ -12,7 +12,7 @@ using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; using ERPCore.System.Time; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IPurchaseInvoiceRepository namespace ERPCore.Services; @@ -24,13 +24,13 @@ namespace ERPCore.Services; /// re-selected); Post reclassifies the GRN clearing balance to Accounts Payable via a /// real GL journal entry (mirrors 's AR recognition); /// vendor payments are then recorded against this aggregate (see -/// ) rather than per-GRN. +/// ) rather than per-GRN. Queries go through +/// ; every write runs inside +/// like . /// public sealed class PurchaseInvoiceService : IPurchaseInvoiceService { - private readonly IRepository _invoices; - private readonly IRepository _vendors; - private readonly IRepository _grns; + private readonly IPurchaseInvoiceRepository _invoices; private readonly ICurrentUser _currentUser; private readonly INumberSequenceService _numbers; private readonly IUnitOfWork _uow; @@ -38,13 +38,10 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService private readonly IGlAccountConfigService _glAccounts; public PurchaseInvoiceService( - IRepository invoices, IRepository vendors, IRepository grns, - ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow, + IPurchaseInvoiceRepository invoices, ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { _invoices = invoices; - _vendors = vendors; - _grns = grns; _currentUser = currentUser; _numbers = numbers; _uow = uow; @@ -54,45 +51,30 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService public async Task> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default) { - IQueryable q = _invoices.Query().AsNoTracking().Include(x => x.Lines); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(x => EF.Functions.Like(x.DocNo, $"%{term}%") - || EF.Functions.Like(x.VendorInvoiceNo, $"%{term}%") - || EF.Functions.Like(x.VendorSnapshotName, $"%{term}%")); - } - if (status is not null) q = q.Where(x => x.Status == status); - if (vendorId is not null) q = q.Where(x => x.VendorId == vendorId); - - var total = await q.CountAsync(ct); - var rows = await q.OrderByDescending(x => x.PurchaseInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); - + var (rows, total) = await _invoices.ListPagedAsync(query.Q, status, vendorId, query.Skip, query.PageSize, ct); return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); } public async Task?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default) { - var invoice = await _invoices.Query().AsNoTracking() - .Include(x => x.Lines).ThenInclude(l => l.Grn) - .FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct); + var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: false, ct); return invoice is null ? null : new ETagged(MapInvoice(invoice), invoice.RowVersion); } public async Task> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default) { - var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct) + var vendor = await _invoices.GetVendorAsync(request.VendorId, ct) ?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422); var vendorInvoiceNo = request.VendorInvoiceNo.Trim(); - await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: null, ct); - var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: null, ct); var branchId = grns[0].BranchId; var invoice = await _uow.ExecuteInTransactionAsync(async token => { + await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: null, token); + var entity = new PurchaseInvoice { DocNo = await _numbers.NextAsync(DocumentTypes.PurchaseInvoice, branchId, token), @@ -120,9 +102,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService public async Task> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default) { - var invoice = await _invoices.Query() - .Include(x => x.Lines).ThenInclude(l => l.Grn) - .FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct) + var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: true, ct) ?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found."); if (invoice.RowVersion != expectedRowVersion) @@ -130,34 +110,35 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService if (invoice.Status != PurchaseInvoiceStatus.Draft) throw new DomainException(ErrorCodes.PurchaseInvoiceNotEditable, $"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be edited.", 409); - var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct) + var vendor = await _invoices.GetVendorAsync(request.VendorId, ct) ?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422); var vendorInvoiceNo = request.VendorInvoiceNo.Trim(); - await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: purchaseInvoiceId, ct); - var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: purchaseInvoiceId, ct); - // Free GRNs that were linked to this invoice but are no longer selected. - var newGrnIds = grns.Select(g => g.GrnId).ToHashSet(); - foreach (var line in invoice.Lines) - if (line.Grn is not null && !newGrnIds.Contains(line.Grn.GrnId)) line.Grn.PurchaseInvoiceId = null; - - invoice.VendorId = request.VendorId; - invoice.BranchId = grns[0].BranchId; - invoice.VendorInvoiceNo = vendorInvoiceNo; - invoice.VendorInvoiceDate = request.VendorInvoiceDate; - invoice.VendorSnapshotName = vendor.Name; - invoice.VendorSnapshotTaxNo = vendor.TaxReg; - invoice.Lines.Clear(); - foreach (var line in grns.Select(BuildLine)) invoice.Lines.Add(line); - foreach (var grn in grns) grn.PurchaseInvoice = invoice; - Recalculate(invoice); - invoice.UpdatedAt = DateTime.UtcNow; - try { - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(async token => + { + await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: purchaseInvoiceId, token); + + // Free GRNs that were linked to this invoice but are no longer selected. + var newGrnIds = grns.Select(g => g.GrnId).ToHashSet(); + foreach (var line in invoice.Lines) + if (line.Grn is not null && !newGrnIds.Contains(line.Grn.GrnId)) line.Grn.PurchaseInvoiceId = null; + + invoice.VendorId = request.VendorId; + invoice.BranchId = grns[0].BranchId; + invoice.VendorInvoiceNo = vendorInvoiceNo; + invoice.VendorInvoiceDate = request.VendorInvoiceDate; + invoice.VendorSnapshotName = vendor.Name; + invoice.VendorSnapshotTaxNo = vendor.TaxReg; + invoice.Lines.Clear(); + foreach (var line in grns.Select(BuildLine)) invoice.Lines.Add(line); + foreach (var grn in grns) grn.PurchaseInvoice = invoice; + Recalculate(invoice); + invoice.UpdatedAt = DateTime.UtcNow; + }, ct); } catch (DbUpdateConcurrencyException) { @@ -169,9 +150,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService public async Task PostAsync(int purchaseInvoiceId, CancellationToken ct = default) { - var invoice = await _invoices.Query() - .Include(x => x.Lines).ThenInclude(l => l.Grn) - .FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct) + var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: true, ct) ?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found."); if (invoice.Status != PurchaseInvoiceStatus.Draft) @@ -212,9 +191,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService public async Task CancelAsync(int purchaseInvoiceId, CancellationToken ct = default) { - var invoice = await _invoices.Query() - .Include(x => x.Lines).ThenInclude(l => l.Grn) - .FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct) + var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: true, ct) ?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found."); if (invoice.Status != PurchaseInvoiceStatus.Draft) @@ -222,47 +199,41 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService var dto = MapInvoice(invoice); - // Cancelling frees the consolidated GRNs so they can be picked up by a later invoice — - // the link rows are removed (the unique index on GrnId only tracks the active link), - // while the Cancelled header row itself remains for audit history. - foreach (var line in invoice.Lines) - if (line.Grn is not null) line.Grn.PurchaseInvoiceId = null; - invoice.Lines.Clear(); - invoice.Status = PurchaseInvoiceStatus.Cancelled; - invoice.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + // Cancelling frees the consolidated GRNs so they can be picked up by a later invoice — + // the link rows are removed (the unique index on GrnId only tracks the active link), + // while the Cancelled header row itself remains for audit history. + foreach (var line in invoice.Lines) + if (line.Grn is not null) line.Grn.PurchaseInvoiceId = null; + invoice.Lines.Clear(); + invoice.Status = PurchaseInvoiceStatus.Cancelled; + invoice.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); return dto with { Status = PurchaseInvoiceStatus.Cancelled, UpdatedAt = invoice.UpdatedAt }; } public async Task> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default) { - return await _grns.Query().AsNoTracking() - .Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null) - .OrderBy(g => g.PostedAt) + return (await _invoices.ListEligibleGrnsAsync(vendorId, ct)) .Select(g => new GrnEligibleForInvoiceDto( - g.GrnId, g.DocNo, g.PostedAt ?? g.CreatedAt, g.WarehouseId, g.Lines.Count, - g.Lines.Sum(l => l.ReceivedValue), g.Lines.Sum(l => l.VatAmount), g.Lines.Sum(l => l.LineTotal))) - .ToListAsync(ct); + g.GrnId, g.DocNo, g.ReceivedAt, g.WarehouseId, g.LineCount, g.Subtotal, g.VatTotal, g.GrandTotal)) + .ToList(); } private async Task EnsureVendorInvoiceNoIsUniqueAsync(int vendorId, string vendorInvoiceNo, int? excludingPurchaseInvoiceId, CancellationToken ct) { - var duplicate = await _invoices.Query().AsNoTracking().AnyAsync(x => - x.VendorId == vendorId - && x.Status != PurchaseInvoiceStatus.Cancelled - && x.VendorInvoiceNo == vendorInvoiceNo - && (excludingPurchaseInvoiceId == null || x.PurchaseInvoiceId != excludingPurchaseInvoiceId), ct); - if (duplicate) + if (await _invoices.VendorInvoiceNoExistsAsync(vendorId, vendorInvoiceNo, excludingPurchaseInvoiceId, ct)) throw new DomainException(ErrorCodes.DuplicateVendorInvoiceNo, $"Vendor invoice number '{vendorInvoiceNo}' already exists for this vendor.", 409); } - private async Task> LoadAndValidateGrnsAsync(int vendorId, List grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct) + private async Task> LoadAndValidateGrnsAsync(int vendorId, List grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct) { var distinctIds = grnIds.Distinct().ToList(); - var grns = await _grns.Query().Include(g => g.Lines) - .Where(g => distinctIds.Contains(g.GrnId)).ToListAsync(ct); + var grns = await _invoices.ListGrnsForInvoicingAsync(distinctIds, ct); if (grns.Count != distinctIds.Count) throw new DomainException(ErrorCodes.Validation, "One or more selected GRNs do not exist.", 422); diff --git a/Backend/ERPCore/Services/PurchaseOrderService.cs b/Backend/ERPCore/Services/PurchaseOrderService.cs index 39e93ad..a9e6f5f 100644 --- a/Backend/ERPCore/Services/PurchaseOrderService.cs +++ b/Backend/ERPCore/Services/PurchaseOrderService.cs @@ -10,7 +10,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; using ERPCore.System.Time; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IPurchaseOrderRepository namespace ERPCore.Services; @@ -18,18 +18,14 @@ namespace ERPCore.Services; /// 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. +/// once any receipt exists. Queries go through ; every write runs +/// inside like . /// public sealed class PurchaseOrderService : IPurchaseOrderService { private const string BaseCurrency = "LKR"; - private readonly IRepository _pos; - private readonly IRepository _vendors; - private readonly IRepository _requisitions; - private readonly IRepository _items; - private readonly IRepository _uoms; - private readonly IRepository _warehouses; + private readonly IPurchaseOrderRepository _pos; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; private readonly INotificationService _notifications; @@ -38,22 +34,15 @@ public sealed class PurchaseOrderService : IPurchaseOrderService private readonly IUnitOfWork _uow; public PurchaseOrderService( - IRepository pos, IRepository vendors, IRepository requisitions, - IRepository items, IRepository uoms, IRepository warehouses, - INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications, + IPurchaseOrderRepository pos, INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications, IBranchConfigService branchConfig, IProcurementConfigService procurementConfig, IUnitOfWork uow) { - _procurementConfig = procurementConfig; _pos = pos; - _vendors = vendors; - _requisitions = requisitions; - _items = items; - _uoms = uoms; - _warehouses = warehouses; _numbers = numbers; _currentUser = currentUser; _notifications = notifications; _branchConfig = branchConfig; + _procurementConfig = procurementConfig; _uow = uow; } @@ -61,40 +50,16 @@ public sealed class PurchaseOrderService : IPurchaseOrderService PageQuery query, PurchaseOrderStatus? status, int? vendorId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default) { - var q = _pos.Query().AsNoTracking().Include(p => p.Lines).AsQueryable(); - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - // Doc no., or the vendor's name / code (the vendor is the table's first column). - var vendorIdsMatching = _vendors.Query() - .Where(v => EF.Functions.Like(v.Name, $"%{term}%") || EF.Functions.Like(v.Code, $"%{term}%")) - .Select(v => v.VendorId); - q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || vendorIdsMatching.Contains(p.VendorId)); - } - if (status is not null) q = q.Where(p => p.Status == status); - if (vendorId is not null) q = q.Where(p => p.VendorId == vendorId); // Created date, as a business (Colombo) day: [start of `from`, start of the day after `to`). - if (from is DateOnly fromDay) - { - var start = BusinessClock.StartOfDayUtc(fromDay); - q = q.Where(p => p.CreatedAt >= start); - } - if (to is DateOnly toDay) - { - var end = BusinessClock.StartOfDayUtc(toDay.AddDays(1)); - q = q.Where(p => p.CreatedAt < end); - } - - var total = await q.CountAsync(ct); - var rows = await ApplySort(q, query.Sort) - .Skip(query.Skip).Take(query.PageSize) - .ToListAsync(ct); + var criteria = new PurchaseOrderListCriteria( + query.Q, status, vendorId, + from is DateOnly fromDay ? BusinessClock.StartOfDayUtc(fromDay) : null, + to is DateOnly toDay ? BusinessClock.StartOfDayUtc(toDay.AddDays(1)) : null, + query.Sort); + var (rows, total) = await _pos.ListPagedAsync(criteria, query.Skip, query.PageSize, ct); var flags = await _procurementConfig.GetFlagsAsync(ct); - var vendorIds = rows.Select(p => p.VendorId).Distinct().ToList(); - var vendorMap = await _vendors.Query().AsNoTracking() - .Where(v => vendorIds.Contains(v.VendorId)) - .ToDictionaryAsync(v => v.VendorId, ct); + var vendorMap = await _pos.GetVendorsAsync(rows.Select(p => p.VendorId).Distinct().ToList(), ct); var summaries = rows.Select(p => new PurchaseOrderSummaryDto( p.PoId, p.DocNo, p.VendorId, p.BranchId, p.Status, p.ApprovalRequired, p.CreatedBy, p.CreatedAt, ComputeTotals(p.Lines), @@ -103,37 +68,9 @@ public sealed class PurchaseOrderService : IPurchaseOrderService return PagedResponse.Create(summaries, query.Page, query.PageSize, total); } - /// - /// Server-side sort so paging stays correct: created / vendor / total / status, a leading - /// - for descending. Default (and any unknown key) is newest first. PoId is the tiebreaker, so a page - /// boundary never repeats or skips a row. - /// - private IQueryable ApplySort(IQueryable q, string? sort) - { - var key = (sort ?? "-created").Trim(); - var desc = key.StartsWith('-'); - key = key.TrimStart('-', '+').ToLowerInvariant(); - - IOrderedQueryable ordered = key switch - { - "vendor" => desc - ? q.OrderByDescending(p => _vendors.Query().Where(v => v.VendorId == p.VendorId).Select(v => v.Name).FirstOrDefault()) - : q.OrderBy(p => _vendors.Query().Where(v => v.VendorId == p.VendorId).Select(v => v.Name).FirstOrDefault()), - "total" => desc - ? q.OrderByDescending(p => p.Lines.Sum(l => l.Qty * l.UnitPrice + l.TaxAmount)) - : q.OrderBy(p => p.Lines.Sum(l => l.Qty * l.UnitPrice + l.TaxAmount)), - "status" => desc ? q.OrderByDescending(p => p.Status) : q.OrderBy(p => p.Status), - "created" => desc ? q.OrderByDescending(p => p.CreatedAt) : q.OrderBy(p => p.CreatedAt), - _ => q.OrderByDescending(p => p.CreatedAt) - }; - return ordered.ThenByDescending(p => p.PoId); - } - public async Task?> GetAsync(int poId, CancellationToken ct = default) { - var po = await _pos.Query().AsNoTracking() - .Include(p => p.Lines) - .FirstOrDefaultAsync(p => p.PoId == poId, ct); + var po = await _pos.GetWithLinesAsync(poId, tracking: false, ct); if (po is null) return null; return new ETagged(await ToDtoAsync(po, ct), po.RowVersion); } @@ -175,10 +112,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService public async Task> UpdateAsync( int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default) { - var po = await _pos.Query() - .Include(p => p.Lines) - .FirstOrDefaultAsync(p => p.PoId == poId, ct) - ?? throw new NotFoundException($"Purchase order {poId} was not found."); + var po = await LoadForUpdateAsync(poId, ct); if (po.RowVersion != expectedRowVersion) throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412); @@ -189,18 +123,20 @@ public sealed class PurchaseOrderService : IPurchaseOrderService 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; - po.UpdatedAt = DateTime.UtcNow; - - // Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line). - po.Lines.Clear(); - foreach (var input in request.Lines) - po.Lines.Add(ToLine(input)); - try { - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + po.VendorId = request.VendorId; + po.RequisitionId = request.RequisitionId; + po.UpdatedAt = DateTime.UtcNow; + + // Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line). + po.Lines.Clear(); + foreach (var input in request.Lines) + po.Lines.Add(ToLine(input)); + return Task.CompletedTask; + }, ct); } catch (DbUpdateConcurrencyException) { @@ -214,11 +150,14 @@ public sealed class PurchaseOrderService : IPurchaseOrderService { 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 _uow.ExecuteInTransactionAsync(_ => + { + po.Status = PurchaseOrderStatus.Approved; + po.ApprovedBy = _currentUser.AuditUserId; + po.ApprovedAt = DateTime.UtcNow; + po.UpdatedAt = po.ApprovedAt; + return Task.CompletedTask; + }, ct); await _notifications.RaiseAsync("purchase.order.approved", "Purchase Order Approved", $"Purchase order {po.DocNo} was approved.", nameof(PurchaseOrder), po.PoId, ct); return await ToDtoAsync(po, ct); @@ -228,11 +167,14 @@ public sealed class PurchaseOrderService : IPurchaseOrderService { var po = await LoadForApprovalDecisionAsync(poId, ct); - // Rejected, not Cancelled — a reject is "revise and resubmit", not "abandon" - and not Draft either, so the - // list and the creator can tell it was turned down. - po.Status = PurchaseOrderStatus.Rejected; - po.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + // Rejected, not Cancelled — a reject is "revise and resubmit", not "abandon" - and not Draft either, so the + // list and the creator can tell it was turned down. + po.Status = PurchaseOrderStatus.Rejected; + po.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, 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); @@ -243,10 +185,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService /// caller must be an Admin who isn't the PO's own creator (self-approval is blocked). private async Task 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."); + var po = await LoadForUpdateAsync(poId, ct); if (po.Status != PurchaseOrderStatus.PendingApproval) throw new DomainException(ErrorCodes.PoNotPendingApproval, $"Purchase order {poId} is {po.Status} and is not awaiting approval.", 409); @@ -262,19 +201,19 @@ public sealed class PurchaseOrderService : IPurchaseOrderService public async Task CancelAsync(int poId, string? reason, CancellationToken ct = default) { - var po = await _pos.Query() - .Include(p => p.Lines) - .FirstOrDefaultAsync(p => p.PoId == poId, ct) - ?? throw new NotFoundException($"Purchase order {poId} was not found."); + var po = await LoadForUpdateAsync(poId, ct); if (po.Lines.Any(l => l.QtyReceived > 0)) throw new ConflictException($"Purchase order {poId} cannot be cancelled because goods have been received against it."); if (po.Status != PurchaseOrderStatus.Cancelled) { - po.Status = PurchaseOrderStatus.Cancelled; - po.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + po.Status = PurchaseOrderStatus.Cancelled; + po.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, ct); } return await ToDtoAsync(po, ct); @@ -282,18 +221,18 @@ public sealed class PurchaseOrderService : IPurchaseOrderService public async Task SubmitAsync(int poId, CancellationToken ct = default) { - var po = await _pos.Query() - .Include(p => p.Lines) - .FirstOrDefaultAsync(p => p.PoId == poId, ct) - ?? throw new NotFoundException($"Purchase order {poId} was not found."); + var po = await LoadForUpdateAsync(poId, ct); if (!IsEditable(po.Status)) throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409); - po.Status = PurchaseOrderStatus.PendingApproval; - po.ApprovalRequired = true; - po.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + po.Status = PurchaseOrderStatus.PendingApproval; + po.ApprovalRequired = true; + po.UpdatedAt = DateTime.UtcNow; + return Task.CompletedTask; + }, 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 await ToDtoAsync(po, ct); @@ -301,18 +240,22 @@ public sealed class PurchaseOrderService : IPurchaseOrderService public async Task DeleteAsync(int poId, CancellationToken ct = default) { - var po = await _pos.Query() - .Include(p => p.Lines) - .FirstOrDefaultAsync(p => p.PoId == poId, ct) - ?? throw new NotFoundException($"Purchase order {poId} was not found."); + var po = await LoadForUpdateAsync(poId, ct); if (!IsEditable(po.Status)) throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be deleted; only a Draft or Rejected order can be deleted.", 409); - _pos.Remove(po); - await _uow.SaveChangesAsync(ct); + await _uow.ExecuteInTransactionAsync(_ => + { + _pos.Remove(po); + return Task.CompletedTask; + }, ct); } + private async Task LoadForUpdateAsync(int poId, CancellationToken ct) + => await _pos.GetWithLinesAsync(poId, tracking: true, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + // FR-PROC-05 (revised): a PO is editable/deletable only while Draft. Submitting locks it. // Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05. private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft or PurchaseOrderStatus.Rejected; @@ -348,14 +291,12 @@ public sealed class PurchaseOrderService : IPurchaseOrderService private async Task ValidateReferencesAsync( int vendorId, int? requisitionId, IReadOnlyCollection lines, CancellationToken ct) { - var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct); - if (vendor is null) - throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422); + var vendor = await _pos.GetVendorAsync(vendorId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422); if (vendor.Status != EntityStatus.Active) throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} is inactive.", 422); - if (requisitionId is not null - && !await _requisitions.Query().AnyAsync(r => r.RequisitionId == requisitionId, ct)) + if (requisitionId is int reqId && !await _pos.RequisitionExistsAsync(reqId, ct)) throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422); // Tax is a Procurement-configuration option: with it off, the form hides the field, so a @@ -363,30 +304,25 @@ public sealed class PurchaseOrderService : IPurchaseOrderService if (!(await _procurementConfig.GetFlagsAsync(ct)).PoTaxEnabled && lines.Any(l => l.Tax != 0)) throw new DomainException(ErrorCodes.ConfigDisabled, "Tax on purchase orders is turned off in Procurement configuration.", 422); - await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct); - await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct); + var itemIds = lines.Select(l => l.ItemId).Distinct().ToList(); + EnsureAllExist(itemIds, await _pos.ListExistingItemIdsAsync(itemIds, ct), "Item"); + var warehouseIds = lines.Select(l => l.WarehouseId).Distinct().ToList(); + EnsureAllExist(warehouseIds, await _pos.ListExistingWarehouseIdsAsync(warehouseIds, ct), "Warehouse"); } /// 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. private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable 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); + var mismatched = await _pos.ListWarehousesOutsideBranchAsync(warehouseIds.Distinct().ToList(), branchId, 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 keySource, IEnumerable requested, string label, CancellationToken ct) + private static void EnsureAllExist(IReadOnlyCollection requested, IReadOnlyCollection found, string label) { - var ids = requested.Distinct().ToList(); - var found = await keySource.Where(k => ids.Contains(k)).ToListAsync(ct); - var missing = ids.Except(found).ToList(); + var missing = requested.Except(found).ToList(); if (missing.Count > 0) throw new DomainException(ErrorCodes.Validation, $"{label}(s) not found: {string.Join(", ", missing)}.", 422); } @@ -400,7 +336,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService private async Task ToDtoAsync(PurchaseOrder p, CancellationToken ct) { - var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == p.VendorId, ct); + var vendor = await _pos.GetVendorAsync(p.VendorId, ct); var flags = await _procurementConfig.GetFlagsAsync(ct); return Map(p, vendor is null ? null : ToVendorDto(vendor, flags)); } diff --git a/Backend/ERPCore/Services/PurchaseReturnService.cs b/Backend/ERPCore/Services/PurchaseReturnService.cs index f9aea5f..b31f906 100644 --- a/Backend/ERPCore/Services/PurchaseReturnService.cs +++ b/Backend/ERPCore/Services/PurchaseReturnService.cs @@ -10,7 +10,6 @@ using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; using ERPCore.System.Time; -using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -24,21 +23,15 @@ namespace ERPCore.Services; /// (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 posted — reuses -/// Grn's own account config since it's reversing that posting. +/// Grn's own account config since it's reversing that posting. Queries go through +/// ; the post runs inside +/// like . /// public sealed class PurchaseReturnService : IPurchaseReturnService { - private readonly IRepository _returns; - private readonly IRepository _vendors; - private readonly IRepository _warehouses; - private readonly IRepository _items; - private readonly IRepository _reasonCodes; - private readonly IRepository _grnLines; - private readonly IRepository _layers; - private readonly IRepository _ledger; + private readonly IPurchaseReturnRepository _returns; private readonly IStockMutator _mutator; private readonly IFifoCostingService _fifo; - private readonly IRepository _serials; private readonly ISerialTrackingService _serialTracking; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -48,23 +41,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService private readonly IGlAccountConfigService _glAccounts; public PurchaseReturnService( - IRepository returns, IRepository vendors, IRepository warehouses, - IRepository items, IRepository reasonCodes, IRepository grnLines, - IRepository layers, IRepository ledger, IStockMutator mutator, IFifoCostingService fifo, - IRepository serials, ISerialTrackingService serialTracking, + IPurchaseReturnRepository returns, IStockMutator mutator, IFifoCostingService fifo, ISerialTrackingService serialTracking, INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { - _serials = serials; - _serialTracking = serialTracking; _returns = returns; - _vendors = vendors; - _warehouses = warehouses; - _items = items; - _reasonCodes = reasonCodes; - _grnLines = grnLines; - _layers = layers; - _ledger = ledger; + _serialTracking = serialTracking; _mutator = mutator; _fifo = fifo; _numbers = numbers; @@ -78,42 +60,20 @@ public sealed class PurchaseReturnService : IPurchaseReturnService public async Task> ListAsync( PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default) { - var q = _returns.Query().AsNoTracking(); - - if (!string.IsNullOrWhiteSpace(query.Q)) - { - var term = query.Q.Trim(); - q = q.Where(r => EF.Functions.Like(r.DocNo, $"%{term}%")); - } - if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId); - if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId); - - var total = await q.CountAsync(ct); - var rows = await q.OrderByDescending(r => r.ReturnId) - .Skip(query.Skip).Take(query.PageSize) - .Select(r => new PurchaseReturnSummaryDto( - r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, - r.CreatedBy, r.CreatedAt, r.Lines.Count, r.GlJournalNo)) - .ToListAsync(ct); - - return PagedResponse.Create(rows, query.Page, query.PageSize, total); + var (rows, total) = await _returns.ListPagedAsync(query.Q, vendorId, warehouseId, query.Skip, query.PageSize, ct); + var items = rows.Select(x => new PurchaseReturnSummaryDto( + x.Return.ReturnId, x.Return.DocNo, x.Return.VendorId, x.Return.WarehouseId, x.Return.ReasonCodeId, x.Return.Status, + x.Return.CreatedBy, x.Return.CreatedAt, x.LineCount, x.Return.GlJournalNo)).ToList(); + return PagedResponse.Create(items, query.Page, query.PageSize, total); } public async Task GetAsync(int returnId, CancellationToken ct = default) { - var ret = await _returns.Query().AsNoTracking() - .Include(r => r.Lines) - .FirstOrDefaultAsync(r => r.ReturnId == returnId, ct); + var ret = await _returns.GetWithLinesAsync(returnId, ct); if (ret is null) return null; // Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup. - var ledgerRefs = await _ledger.Query().AsNoTracking() - .Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId) - .OrderBy(l => l.LedgerId) - .Select(l => l.LedgerId) - .ToListAsync(ct); - - return ToDto(ret, ledgerRefs); + return ToDto(ret, await _returns.ListLedgerIdsAsync(returnId, ct)); } public async Task CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default) @@ -121,12 +81,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService if (request.ReasonCodeId is null) throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for a purchase return.", 400); - if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct)) + if (!await _returns.VendorExistsAsync(request.VendorId, ct)) throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422); - if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct)) + if (!await _returns.WarehouseExistsAsync(request.WarehouseId, ct)) throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422); - var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct) + var reason = await _returns.GetReasonCodeAsync(request.ReasonCodeId.Value, ct) ?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422); if (reason.Context != ReasonContext.Return) throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422); @@ -140,12 +100,13 @@ public sealed class PurchaseReturnService : IPurchaseReturnService foreach (var line in request.Lines) { - var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct) + var item = await _returns.GetItemAsync(line.ItemId, ct) ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); // Rejected-line serials are checked against that line's own layers below; anything // else must name serials actually on hand (D6: or leave only pre-tracking stock). - var isRejectedLine = line.GrnLineId is not null && await _grnLines.Query().AnyAsync(g => g.GrnLineId == line.GrnLineId && g.HoldStatus == HoldStatus.Rejected, ct); + var grnLine = line.GrnLineId is int grnLineId ? await _returns.GetGrnLineAsync(grnLineId, ct) : null; + var isRejectedLine = grnLine?.HoldStatus == HoldStatus.Rejected; if (!isRejectedLine || line.SerialIds is { Count: > 0 }) serialsByLine[line] = await _serialTracking.ResolveOutboundAsync( item, request.WarehouseId, line.Qty, line.SerialIds, documentSerials, @@ -155,15 +116,14 @@ public sealed class PurchaseReturnService : IPurchaseReturnService if (line.GrnLineId is not null) { - var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct) - ?? throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} does not exist.", 422); + if (grnLine is null) + 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; + var remaining = await _returns.SumRemainingOnGrnLineAsync(grnLine.GrnLineId, ct); 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); @@ -210,10 +170,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService var balances = new Dictionary(); 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); + var layers = await _returns.ListOpenLayersForGrnLineAsync(line.GrnLineId!.Value, token); // A serial line's layers are one per unit: named serials take exactly theirs; // with none named, the whole remaining line may go back (every serial on it). @@ -230,8 +187,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService if (line.Qty != layers.Sum(l => l.QtyRemaining)) throw new DomainException(ErrorCodes.TrackingRequired, $"GRN line {line.GrnLineId} is serial-tracked; select which {line.Qty} serial(s) go back.", 422); - named = await _serials.Query() - .Where(s => layers.Select(l => l.SerialId).Contains(s.SerialId)).ToListAsync(token); + named = await _returns.ListSerialsAsync(layers.Where(l => l.SerialId != null).Select(l => l.SerialId!.Value).ToList(), token); serialsByLine[line] = named; } @@ -260,7 +216,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService // Serial numbers switched off: serial units went back oldest-first without being // named — still record them as returned so the register doesn't show them in stock. if (named.Count == 0 && takenSerialIds.Count > 0) - serialsByLine[line] = await _serials.Query().Where(s => takenSerialIds.Contains(s.SerialId)).ToListAsync(token); + serialsByLine[line] = await _returns.ListSerialsAsync(takenSerialIds, token); } foreach (var serials in serialsByLine.Values) diff --git a/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx b/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx index 103f74a..ab00524 100644 --- a/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx +++ b/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx @@ -1,15 +1,17 @@ + "use client" import { Fragment } from "react" -import Link from "next/link" import { usePathname } from "next/navigation" -import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" -// Per-segment display labels. Deliberately separate from Header.tsx's -// titleFromPath (which resolves one *full* path to a page title) — a -// breadcrumb needs a label per *segment*, including grouping segments that -// have no page of their own (e.g. "receiving", "grn"). const SEGMENT_LABELS: Record = { dashboard: "Dashboard", products: "Items", @@ -48,55 +50,81 @@ const SEGMENT_LABELS: Record = { "owner-report": "Day-End Owner Report", } -function labelFor(segment: string): string { - if (SEGMENT_LABELS[segment]) return SEGMENT_LABELS[segment] - if (/^[0-9]+$/.test(segment)) return `#${segment}` - return segment.charAt(0).toUpperCase() + segment.slice(1) -} - -/** - * Grouping paths with no page of their own (no page.tsx), mapped to where their menu lands — the - * same `landingHref`s as the sidebar. Linking the raw path would 404. (HRM has its own hub page now.) - */ const GROUP_LANDING: Record = { "/dashboard/production": "/dashboard/production/runs", "/dashboard/receiving": "/dashboard/receiving/grn", - "/dashboard/sales/bundles": "/dashboard/sales/bundles/templates", + "/dashboard/sales/bundles": + "/dashboard/sales/bundles/templates", "/dashboard/help/tickets": "/dashboard/help", } -const linkClass = - "truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm" +function labelFor(segment: string): string { + if (SEGMENT_LABELS[segment]) { + return SEGMENT_LABELS[segment] + } + + if (/^[0-9]+$/.test(segment)) { + return `#${segment}` + } + + return segment + .split("-") + .map( + (word) => + word.charAt(0).toUpperCase() + word.slice(1) + ) + .join(" ") +} export function Breadcrumbs() { const pathname = usePathname() || "/dashboard" const segments = pathname.split("/").filter(Boolean) - // Nothing to show above the dashboard root itself. - if (segments.length <= 1) return null + if (segments.length <= 1) { + return null + } - const crumbs = segments.map((segment, i) => { - const path = "/" + segments.slice(0, i + 1).join("/") - return { key: path, href: GROUP_LANDING[path] ?? path, label: labelFor(segment) } + const crumbs = segments.map((segment, index) => { + const path = `/${segments + .slice(0, index + 1) + .join("/")}` + + return { + key: path, + href: GROUP_LANDING[path] ?? path, + label: labelFor(segment), + } }) + // Remove consecutive duplicate labels, e.g. Sales > Sales. + const uniqueCrumbs = crumbs.filter( + (crumb, index, array) => + index === 0 || + crumb.label.toLowerCase() !== + array[index - 1].label.toLowerCase() + ) + return ( - {crumbs.map((crumb, i) => { - const isLast = i === crumbs.length - 1 + {uniqueCrumbs.map((crumb, index) => { + const isLast = index === uniqueCrumbs.length - 1 + return ( + {index > 0 && } + {isLast ? ( - {crumb.label} - ) : ( - + {crumb.label} - + + ) : ( + + {crumb.label} + )} - {!isLast && } ) })}