fixers 10/22

This commit is contained in:
2026-10-22 11:42:20 +05:30
parent a27bdaea64
commit b6b5a142a0
76 changed files with 3773 additions and 1988 deletions
@@ -16,9 +16,7 @@ namespace ERPCore.Tests;
public class CustomerFieldSwitchTests
{
private static CustomerService Svc(ErpDbContext db) =>
new(new Repository<Customer>(db), new Repository<Warehouse>(db), new FakeBranchConfig(),
new Repository<SalesConfig>(db), new Repository<SalesInvoice>(db), new Repository<SalesInvoicePayment>(db),
new PassThroughUnitOfWork(db));
new(new CustomerRepository(db), new FakeBranchConfig(), new PassThroughUnitOfWork(db));
private static async Task SwitchesAsync(ErpDbContext db, Action<SalesConfig> tweak)
{
@@ -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<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db), branch, clock, config);
return new DayEndReportService(
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoice>(db),
new Repository<SalesInvoicePayment>(db), new Repository<StockLedger>(db),
new Repository<Expense>(db), new Repository<BankDeposit>(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()
+1 -4
View File
@@ -396,10 +396,7 @@ public class DualApprovalTests
(GlAccountRole.InventoryLoss, "5900")),
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
new Repository<ProductConfig>(db), user));
var prices = new PriceAdjustmentService(
new Repository<PriceAdjustment>(db), new Repository<PriceAdjustmentLine>(db), new Repository<Item>(db),
new Repository<ItemPriceHistory>(db), new Repository<User>(db), new Repository<ApprovalConfig>(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 };
}
@@ -45,7 +45,7 @@ public class ProcurementAndOpeningBalanceTests
}
private static ProcurementConfigService ConfigService(ErpDbContext db) =>
new(new Repository<ProcurementConfig>(db), new TestCurrentUser(), new PassThroughUnitOfWork(db));
new(new ProcurementConfigRepository(db), new TestCurrentUser(), new PassThroughUnitOfWork(db));
private static VendorService VendorSvc(ErpDbContext db) =>
new(new Repository<Vendor>(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<PurchaseOrder>(db), new Repository<Vendor>(db), new Repository<Requisition>(db),
new Repository<Item>(db), new Repository<Uom>(db), new Repository<Warehouse>(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<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
return new OpeningStockService(
new Repository<OpeningBalanceRun>(db), new Repository<Item>(db), new Repository<Warehouse>(db),
new Repository<StockLedger>(db), new Repository<JournalEntryStub>(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<Item>? tweak = null)
@@ -145,9 +145,7 @@ internal static class BankDepositTestSupport
}
public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
=> new(new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(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)
{
+24
View File
@@ -116,6 +116,30 @@ builder.Services.AddScoped<IClaimsTransformation, ShadowUserClaimsTransformation
// Unit of work + generic repository base
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Aggregate-specific repositories (bespoke queries on top of the generic base)
builder.Services.AddScoped<IBrandRepository, BrandRepository>();
builder.Services.AddScoped<IBundleSaleRepository, BundleSaleRepository>();
builder.Services.AddScoped<ICashInHandRepository, CashInHandRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
builder.Services.AddScoped<ICountRepository, CountRepository>();
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddScoped<IDayEndReportRepository, DayEndReportRepository>();
builder.Services.AddScoped<IExpensePaymentRepository, ExpensePaymentRepository>();
builder.Services.AddScoped<IExpenseRepository, ExpenseRepository>();
builder.Services.AddScoped<IExpenseSetupRepository, ExpenseSetupRepository>();
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
builder.Services.AddScoped<IItemImageRepository, ItemImageRepository>();
builder.Services.AddScoped<IItemRepository, ItemRepository>();
builder.Services.AddScoped<IItemTypeRepository, ItemTypeRepository>();
builder.Services.AddScoped<INumberSequenceRepository, NumberSequenceRepository>();
builder.Services.AddScoped<IOpeningStockRepository, OpeningStockRepository>();
builder.Services.AddScoped<IPriceAdjustmentRepository, PriceAdjustmentRepository>();
builder.Services.AddScoped<IProcurementConfigRepository, ProcurementConfigRepository>();
builder.Services.AddScoped<IProductConfigRepository, ProductConfigRepository>();
builder.Services.AddScoped<IPurchaseInvoicePaymentRepository, PurchaseInvoicePaymentRepository>();
builder.Services.AddScoped<IPurchaseInvoiceRepository, PurchaseInvoiceRepository>();
builder.Services.AddScoped<IPurchaseOrderRepository, PurchaseOrderRepository>();
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
// Master-data services (docs/11 §2)
builder.Services.AddScoped<ICustomerService, CustomerService>();
@@ -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;
/// <summary>EF implementation of <see cref="IBrandRepository"/>.</summary>
public sealed class BrandRepository : Repository<Brand>, IBrandRepository
{
public BrandRepository(ErpDbContext db) : base(db) { }
public async Task<(IReadOnlyList<Brand> 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<Brand?> GetForReadAsync(int brandId, CancellationToken ct = default)
=> Set.AsNoTracking().FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
public Task<bool> 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);
}
}
@@ -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;
/// <summary>EF implementation of <see cref="IBundleSaleRepository"/>.</summary>
public sealed class BundleSaleRepository : Repository<BundleSale>, IBundleSaleRepository
{
public BundleSaleRepository(ErpDbContext db) : base(db) { }
private DbSet<BundleSaleTemplate> Templates => Db.Set<BundleSaleTemplate>();
// --- Templates -------------------------------------------------------------------------------
public async Task<(IReadOnlyList<BundleSaleTemplate> Rows, int Total)> ListTemplatesPagedAsync(
string? search, int skip, int take, CancellationToken ct = default)
{
IQueryable<BundleSaleTemplate> 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<BundleSaleTemplate?> 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<BundleSale> Rows, int Total)> ListPagedAsync(
string? search, BundleSaleStatus? status, int? customerId, int? warehouseId, int skip, int take, CancellationToken ct = default)
{
IQueryable<BundleSale> 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<BundleSale?> GetForReadAsync(int bundleSaleId, CancellationToken ct = default)
=> Set.AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct);
public Task<BundleSale?> GetForUpdateAsync(int bundleSaleId, CancellationToken ct = default)
=> Set.Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct);
// --- Lookups ---------------------------------------------------------------------------------
public Task<bool> IsDayClosedAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
=> Db.Set<SalesDayEnd>().AsNoTracking().AnyAsync(x => x.CashierUserId == cashierUserId && x.BusinessDate == businessDate, ct);
public Task<string> GetCustomerSnapshotNameAsync(int customerId, CancellationToken ct = default)
=> Db.Set<Customer>().AsNoTracking().Where(x => x.CustomerId == customerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
public Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default)
=> Db.Set<Item>().AsNoTracking().AnyAsync(x => x.ItemId == itemId, ct);
public Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
=> Db.Set<Warehouse>().AsNoTracking().AnyAsync(x => x.WarehouseId == warehouseId, ct);
public Task<string> GetItemNameAsync(int itemId, CancellationToken ct = default)
=> Db.Set<Item>().AsNoTracking().Where(x => x.ItemId == itemId).Select(x => x.Name).FirstAsync(ct);
}
@@ -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;
/// <summary>EF implementation of <see cref="ICashInHandRepository"/>. Every query is read-only (no tracking).</summary>
public sealed class CashInHandRepository : ICashInHandRepository
{
private readonly ErpDbContext _db;
public CashInHandRepository(ErpDbContext db) => _db = db;
public async Task<decimal> 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<decimal> SumCashOutAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default)
=> ExpensePayments(lo, hi, branchId).SumAsync(p => p.Amount, ct);
public Task<decimal> SumDepositsAsync(DateOnly? lo, DateOnly hi, int? branchId, CancellationToken ct = default)
=> Deposits(lo, hi, branchId).SumAsync(d => d.Amount, ct);
public async Task<IReadOnlyList<(DateTime At, decimal Amount)>> 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<IReadOnlyList<(DateTime At, decimal Amount)>> 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<IReadOnlyList<(DateOnly BusinessDate, decimal Amount)>> 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<SalesSlip> Slips(DateTime? lo, DateTime hi, int? branch)
{
var q = _db.Set<SalesSlip>().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<BundleSale> Bundles(DateTime? lo, DateTime hi, int? branch)
{
var q = _db.Set<BundleSale>().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);
}
/// <summary>Cash received against Posted invoices — whatever day the invoice itself is from.</summary>
private IQueryable<SalesInvoicePayment> InvoicePayments(DateTime? lo, DateTime hi, int? branch)
{
var q = _db.Set<SalesInvoicePayment>().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<ExpensePayment> ExpensePayments(DateTime? lo, DateTime hi, int? branch)
{
var q = _db.Set<ExpensePayment>().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);
}
/// <summary>Posted deposits by business date; <paramref name="lo"/> inclusive (null = no bound), <paramref name="hi"/> exclusive.</summary>
private IQueryable<BankDeposit> Deposits(DateOnly? lo, DateOnly hi, int? branch)
{
var q = _db.Set<BankDeposit>().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);
}
}
@@ -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;
/// <summary>EF implementation of <see cref="ICategoryRepository"/>.</summary>
public sealed class CategoryRepository : Repository<Category>, ICategoryRepository
{
public CategoryRepository(ErpDbContext db) : base(db) { }
private DbSet<SubCategory> SubCategories => Db.Set<SubCategory>();
// --- Categories ------------------------------------------------------------------------------
public async Task<(IReadOnlyList<Category> 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<Category?> GetForReadAsync(int categoryId, CancellationToken ct = default)
=> Set.AsNoTracking().FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
public Task<bool> ExistsAsync(int categoryId, CancellationToken ct = default)
=> Set.AnyAsync(c => c.CategoryId == categoryId, ct);
public Task<bool> 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<SubCategory> 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<SubCategory?> GetSubCategoryForReadAsync(int subCategoryId, CancellationToken ct = default)
=> SubCategories.AsNoTracking().FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
public async Task<SubCategory?> GetSubCategoryForUpdateAsync(int subCategoryId, CancellationToken ct = default)
=> await SubCategories.FindAsync(new object[] { subCategoryId }, ct);
public Task<bool> 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);
}
@@ -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;
/// <summary>EF implementation of <see cref="ICountRepository"/>.</summary>
public sealed class CountRepository : Repository<StockCount>, ICountRepository
{
public CountRepository(ErpDbContext db) : base(db) { }
private IQueryable<StockLayer> Layers(int itemId, int warehouseId)
=> Db.Set<StockLayer>().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<StockCount?> GetForReadAsync(int countId, CancellationToken ct = default)
=> WithLines(Set.AsNoTracking()).FirstOrDefaultAsync(c => c.CountId == countId, ct);
public Task<StockCount?> GetForUpdateAsync(int countId, CancellationToken ct = default)
=> WithLines(Set).FirstOrDefaultAsync(c => c.CountId == countId, ct);
private static IQueryable<StockCount> WithLines(IQueryable<StockCount> q)
=> q.Include(c => c.Lines).ThenInclude(l => l.Batch)
.Include(c => c.Lines).ThenInclude(l => l.Serial);
// --- Lookups ---------------------------------------------------------------------------------
public Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
=> Db.Set<Warehouse>().AnyAsync(w => w.WarehouseId == warehouseId, ct);
public Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default)
=> Db.Set<Item>().AnyAsync(i => i.ItemId == itemId, ct);
public Task<Item> GetItemAsync(int itemId, CancellationToken ct = default)
=> Db.Set<Item>().AsNoTracking().FirstAsync(i => i.ItemId == itemId, ct);
public Task<ReasonCode?> GetAdjustmentReasonAsync(string code, CancellationToken ct = default)
=> Db.Set<ReasonCode>().AsNoTracking()
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == code, ct);
// --- Stock snapshot --------------------------------------------------------------------------
public async Task<IReadOnlyList<Batch>> ListBatchesAsync(int itemId, CancellationToken ct = default)
=> await Db.Set<Batch>().AsNoTracking().Where(b => b.ItemId == itemId).ToListAsync(ct);
public async Task<decimal> 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<decimal> 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<IReadOnlyList<Serial>> ListCountableSerialsAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> await Db.Set<Serial>().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<bool> HasOpenSerialLayerAsync(int itemId, int warehouseId, int serialId, CancellationToken ct = default)
=> Layers(itemId, warehouseId).AnyAsync(l => l.SerialId == serialId && l.QtyRemaining > 0, ct);
}
@@ -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;
/// <summary>EF implementation of <see cref="ICustomerRepository"/>.</summary>
public sealed class CustomerRepository : Repository<Customer>, ICustomerRepository
{
public CustomerRepository(ErpDbContext db) : base(db) { }
public async Task<(IReadOnlyList<Customer> 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<SalesConfig>().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<IReadOnlyDictionary<int, CustomerReceivable>> GetReceivablesAsync(
IReadOnlyCollection<int> customerIds, CancellationToken ct = default)
{
if (customerIds.Count == 0) return new Dictionary<int, CustomerReceivable>();
var ids = customerIds.ToList();
var dues = await Db.Set<SalesInvoice>().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<SalesInvoicePayment>().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<Customer?> GetForReadAsync(int customerId, CancellationToken ct = default)
=> Set.AsNoTracking().FirstOrDefaultAsync(c => c.CustomerId == customerId, ct);
public Task<bool> 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<bool> 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<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
=> Db.Set<Warehouse>().AnyAsync(w => w.WarehouseId == warehouseId, ct);
public Task<CustomerFieldSwitches?> GetFieldSwitchesAsync(CancellationToken ct = default)
=> Db.Set<SalesConfig>().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);
}
@@ -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;
/// <summary>EF implementation of <see cref="IDayEndReportRepository"/>. Every query is read-only (no tracking).</summary>
public sealed class DayEndReportRepository : IDayEndReportRepository
{
/// <summary>Ids per <c>IN (...)</c> when summing ledger cost, to keep the statement a sane size.</summary>
private const int IdChunk = 1000;
private readonly ErpDbContext _db;
public DayEndReportRepository(ErpDbContext db) => _db = db;
public async Task<IReadOnlyList<DayEndSlipRow>> ListPostedSlipsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default)
{
var q = _db.Set<SalesSlip>().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<IReadOnlyList<DayEndBundleRow>> ListPostedBundlesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default)
{
var q = _db.Set<BundleSale>().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<IReadOnlyList<DayEndInvoiceRow>> ListPostedInvoicesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default)
{
var q = _db.Set<SalesInvoice>().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<IReadOnlyList<DayEndPaymentRow>> ListInvoicePaymentsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default)
{
var q = _db.Set<SalesInvoicePayment>().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<IReadOnlyDictionary<int, decimal>> GetCostByDocAsync(string docType, IEnumerable<int> docIds, CancellationToken ct = default)
{
var result = new Dictionary<int, decimal>();
foreach (var chunk in docIds.Distinct().Chunk(IdChunk))
{
var rows = await _db.Set<StockLedger>().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<IReadOnlyList<DayEndExpenseRow>> ListPostedExpensesAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default)
{
var q = _db.Set<Expense>().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<IReadOnlyList<DayEndDepositRow>> ListPostedDepositsAsync(DateOnly businessDate, int? branchId, CancellationToken ct = default)
{
var q = _db.Set<BankDeposit>().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);
}
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Repositories;
/// <summary>EF implementation of <see cref="IExpensePaymentRepository"/>.</summary>
public sealed class ExpensePaymentRepository : Repository<ExpensePayment>, IExpensePaymentRepository
{
public ExpensePaymentRepository(ErpDbContext db) : base(db) { }
public Task<bool> ExpenseExistsAsync(int expenseId, CancellationToken ct = default)
=> Db.Set<Expense>().AnyAsync(x => x.ExpenseId == expenseId, ct);
public async Task<IReadOnlyList<ExpensePayment>> ListForExpenseAsync(int expenseId, CancellationToken ct = default)
=> await Set.AsNoTracking()
.Where(p => p.ExpenseId == expenseId)
.OrderByDescending(p => p.ExpensePaymentId)
.ToListAsync(ct);
}
@@ -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;
/// <summary>EF implementation of <see cref="IExpenseRepository"/>.</summary>
public sealed class ExpenseRepository : Repository<Expense>, IExpenseRepository
{
public ExpenseRepository(ErpDbContext db) : base(db) { }
public async Task<(IReadOnlyList<Expense> Rows, int Total)> ListPagedAsync(
string? search, ExpenseListFilter filter, DateOnly today, int skip, int take, CancellationToken ct = default)
{
IQueryable<Expense> 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<Expense?> 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<ExpenseTitle?> GetTitleWithCategoryAsync(int expenseTitleId, CancellationToken ct = default)
=> Db.Set<ExpenseTitle>().Include(t => t.Category).FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct);
public Task<string?> GetVendorNameAsync(int vendorId, CancellationToken ct = default)
=> Db.Set<Vendor>().AsNoTracking().Where(v => v.VendorId == vendorId).Select(v => (string?)v.Name).FirstOrDefaultAsync(ct);
// --- Summary report --------------------------------------------------------------------------
private IQueryable<Expense> Posted => Set.AsNoTracking().Where(x => x.Status == ExpenseStatus.Posted);
public async Task<IReadOnlyList<ExpenseSummaryRow>> 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<IReadOnlyList<(decimal BalanceAmount, DateOnly? DueDate)>> 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<int> CountDraftsAsync(CancellationToken ct = default)
=> Set.CountAsync(x => x.Status == ExpenseStatus.Draft, ct);
}
@@ -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;
/// <summary>EF implementation of <see cref="IExpenseSetupRepository"/>.</summary>
public sealed class ExpenseSetupRepository : Repository<ExpenseCategory>, IExpenseSetupRepository
{
public ExpenseSetupRepository(ErpDbContext db) : base(db) { }
private DbSet<ExpenseTitle> Titles => Db.Set<ExpenseTitle>();
// --- Categories ------------------------------------------------------------------------------
public async Task<IReadOnlyList<ExpenseCategory>> 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<ExpenseCategory?> GetWithTitlesForUpdateAsync(int expenseCategoryId, CancellationToken ct = default)
=> Set.Include(c => c.Titles).FirstOrDefaultAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct);
public Task<bool> ExistsAsync(int expenseCategoryId, CancellationToken ct = default)
=> Set.AnyAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct);
public Task<bool> CategoryNameExistsAsync(string name, int excludeCategoryId, CancellationToken ct = default)
=> Set.AnyAsync(c => c.Name == name && c.ExpenseCategoryId != excludeCategoryId, ct);
// --- Titles ----------------------------------------------------------------------------------
public Task<ExpenseTitle?> GetTitleForUpdateAsync(int expenseTitleId, CancellationToken ct = default)
=> Titles.FirstOrDefaultAsync(t => t.ExpenseTitleId == expenseTitleId, ct);
public Task<bool> 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<int> 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);
}
@@ -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;
/// <summary>EF implementation of <see cref="IGrnRepository"/>.</summary>
public sealed class GrnRepository : Repository<Grn>, IGrnRepository
{
public GrnRepository(ErpDbContext db) : base(db) { }
private DbSet<PurchaseOrder> Pos => Db.Set<PurchaseOrder>();
private DbSet<Vendor> Vendors => Db.Set<Vendor>();
// --- GRNs ------------------------------------------------------------------------------------
public async Task<(IReadOnlyList<GrnListRow> 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<Grn> ApplySort(IQueryable<Grn> q, string? sort)
{
var key = (sort ?? "-created").Trim();
var desc = key.StartsWith('-');
key = key.TrimStart('-', '+').ToLowerInvariant();
IOrderedQueryable<Grn> 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<Grn?> 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<Grn?> GetForConfirmAsync(int grnId, CancellationToken ct = default)
=> Set.Include(g => g.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
public Task<Grn?> GetWithLinesForUpdateAsync(int grnId, CancellationToken ct = default)
=> Set.Include(g => g.Lines).FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
// --- Purchase orders -------------------------------------------------------------------------
public Task<PurchaseOrder?> GetPoWithLinesAsync(int poId, CancellationToken ct = default)
=> Pos.AsNoTracking().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
public Task<PurchaseOrder?> GetPoWithLinesForUpdateAsync(int poId, CancellationToken ct = default)
=> Pos.Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
public async Task<PoLine?> GetPoLineForUpdateAsync(int poLineId, CancellationToken ct = default)
=> await Db.Set<PoLine>().FindAsync(new object[] { poLineId }, ct);
public Task<PurchaseOrderStatus?> GetPoStatusAsync(int poId, CancellationToken ct = default)
=> Pos.AsNoTracking().Where(p => p.PoId == poId).Select(p => (PurchaseOrderStatus?)p.Status).FirstOrDefaultAsync(ct);
public async Task<IReadOnlyDictionary<int, string>> GetPoDocNosAsync(IReadOnlyCollection<int> 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<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
=> Db.Set<Warehouse>().AnyAsync(w => w.WarehouseId == warehouseId, ct);
public Task<bool> VendorExistsAsync(int vendorId, CancellationToken ct = default)
=> Vendors.AnyAsync(v => v.VendorId == vendorId, ct);
public async Task<IReadOnlyDictionary<int, Vendor>> GetVendorsAsync(IReadOnlyCollection<int> 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<Item?> GetItemAsync(int itemId, CancellationToken ct = default)
=> Db.Set<Item>().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
public Task<bool> BinIsInWarehouseAsync(int binId, int warehouseId, CancellationToken ct = default)
=> Db.Set<Bin>().AnyAsync(b => b.BinId == binId && b.WarehouseId == warehouseId, ct);
public Task<decimal> GetTaxPercentageAsync(int taxRateId, CancellationToken ct = default)
=> Db.Set<TaxRate>().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct);
// --- Batches & serials -----------------------------------------------------------------------
public Task<Batch?> FindBatchAsync(int itemId, string batchNo, CancellationToken ct = default)
=> Db.Set<Batch>().FirstOrDefaultAsync(b => b.ItemId == itemId && b.BatchNo == batchNo, ct);
public async Task AddBatchAsync(Batch batch, CancellationToken ct = default)
=> await Db.Set<Batch>().AddAsync(batch, ct);
public async Task<IReadOnlyList<string>> ListTakenSerialsAsync(
int itemId, IReadOnlyCollection<string> serialNos, int take, CancellationToken ct = default)
{
var numbers = serialNos.ToList();
return await Db.Set<Serial>().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<Serial>().AddAsync(serial, ct);
public async Task AddSerialMovementAsync(SerialMovement movement, CancellationToken ct = default)
=> await Db.Set<SerialMovement>().AddAsync(movement, ct);
// --- Confirm result (idempotent replay) ------------------------------------------------------
public async Task<IReadOnlyList<StockLayer>> ListLayersForLinesAsync(IReadOnlyCollection<int> grnLineIds, CancellationToken ct = default)
{
var ids = grnLineIds.ToList();
return await Db.Set<StockLayer>().AsNoTracking()
.Where(l => l.GrnLineId != null && ids.Contains(l.GrnLineId.Value)).ToListAsync(ct);
}
public async Task<IReadOnlyList<int>> ListInboundLedgerIdsAsync(int grnId, CancellationToken ct = default)
=> await Db.Set<StockLedger>().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Grn && l.SourceDocId == grnId && l.Direction == Direction.In)
.Select(l => l.LedgerId).ToListAsync(ct);
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Brand data access (FR-MD-09). Owns every query <see cref="Services.BrandService"/> runs, so the
/// service holds only the business rules (uniqueness, concurrency) — 00-CORE §4.
/// </summary>
public interface IBrandRepository : IRepository<Brand>
{
/// <summary>One page of brands, name-ordered, optionally filtered by a name search and status. Read-only (no tracking).</summary>
Task<(IReadOnlyList<Brand> Rows, int Total)> ListPagedAsync(
string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default);
/// <summary>A brand for display, not tracked — use <see cref="IRepository{T}.GetByIdAsync"/> to change one.</summary>
Task<Brand?> GetForReadAsync(int brandId, CancellationToken ct = default);
/// <summary>True when another brand already has this name (case-insensitive), ignoring <paramref name="excludeBrandId"/>.</summary>
Task<bool> NameExistsAsync(string name, int? excludeBrandId = null, CancellationToken ct = default);
}
@@ -0,0 +1,50 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Bundle sale data access — bundle sales, their templates, and the few lookups a bundle sale needs.
/// Owns every query <see cref="Services.BundleSaleService"/> runs, so the service holds only the
/// business rules (pricing, day-end guard, draft-only edits) — 00-CORE §4.
/// </summary>
public interface IBundleSaleRepository : IRepository<BundleSale>
{
// --- Templates -------------------------------------------------------------------------------
/// <summary>One page of templates (with lines), newest first, optionally filtered by code / name / description. Read-only.</summary>
Task<(IReadOnlyList<BundleSaleTemplate> Rows, int Total)> ListTemplatesPagedAsync(
string? search, int skip, int take, CancellationToken ct = default);
/// <summary>A template with its lines, not tracked; null when it doesn't exist.</summary>
Task<BundleSaleTemplate?> GetTemplateForReadAsync(int bundleSaleTemplateId, CancellationToken ct = default);
Task AddTemplateAsync(BundleSaleTemplate template, CancellationToken ct = default);
// --- Bundle sales ----------------------------------------------------------------------------
/// <summary>One page of bundle sales (with lines), newest first, with the list filters applied. Read-only.</summary>
Task<(IReadOnlyList<BundleSale> Rows, int Total)> ListPagedAsync(
string? search, BundleSaleStatus? status, int? customerId, int? warehouseId, int skip, int take, CancellationToken ct = default);
/// <summary>A bundle sale with its lines, not tracked; null when it doesn't exist.</summary>
Task<BundleSale?> GetForReadAsync(int bundleSaleId, CancellationToken ct = default);
/// <summary>A bundle sale with its lines, tracked so changes are saved; null when it doesn't exist.</summary>
Task<BundleSale?> GetForUpdateAsync(int bundleSaleId, CancellationToken ct = default);
// --- Lookups ---------------------------------------------------------------------------------
/// <summary>True when the cashier has already posted day-end for <paramref name="businessDate"/>.</summary>
Task<bool> IsDayClosedAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default);
/// <summary>The customer's display name (falling back to the legal name), as snapshotted onto the sale.</summary>
Task<string> GetCustomerSnapshotNameAsync(int customerId, CancellationToken ct = default);
Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default);
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
/// <summary>The item's name, used as the bundle line description.</summary>
Task<string> GetItemNameAsync(int itemId, CancellationToken ct = default);
}
@@ -0,0 +1,32 @@
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Read-only cash movement queries behind <see cref="Services.CashInHandService"/>. Cash comes in from Posted
/// slips and bundle sales (their <c>CashAmount</c>) 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.
/// <para>
/// Instant ranges are UTC: <c>lo</c> inclusive (null = no lower bound), <c>hi</c> exclusive. Deposits are
/// by business date, same bounds. <c>branchId</c> null = every branch.
/// </para>
/// </summary>
public interface ICashInHandRepository
{
/// <summary>Total cash received (slips + bundle sales + cash invoice payments) in [lo, hi).</summary>
Task<decimal> SumCashInAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default);
/// <summary>Total cash paid out on expenses in [lo, hi).</summary>
Task<decimal> SumCashOutAsync(DateTime? lo, DateTime hi, int? branchId, CancellationToken ct = default);
/// <summary>Total Posted bank deposits with a business date in [lo, hi).</summary>
Task<decimal> SumDepositsAsync(DateOnly? lo, DateOnly hi, int? branchId, CancellationToken ct = default);
/// <summary>Every cash receipt in [lo, hi) as (when, amount), for bucketing by local day.</summary>
Task<IReadOnlyList<(DateTime At, decimal Amount)>> ListCashInAsync(DateTime lo, DateTime hi, int? branchId, CancellationToken ct = default);
/// <summary>Every cash expense payment in [lo, hi) as (when, amount).</summary>
Task<IReadOnlyList<(DateTime At, decimal Amount)>> ListCashOutAsync(DateTime lo, DateTime hi, int? branchId, CancellationToken ct = default);
/// <summary>Every Posted bank deposit with a business date in [lo, hi) as (business date, amount).</summary>
Task<IReadOnlyList<(DateOnly BusinessDate, decimal Amount)>> ListDepositsAsync(DateOnly lo, DateOnly hi, int? branchId, CancellationToken ct = default);
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Category + subcategory data access (FR-MD-04). Owns every query <see cref="Services.CategoryService"/> runs,
/// so the service holds only the business rules (uniqueness, active parent, concurrency) — 00-CORE §4.
/// </summary>
public interface ICategoryRepository : IRepository<Category>
{
// --- Categories ------------------------------------------------------------------------------
/// <summary>One page of categories, name-ordered, optionally filtered by a name search and status. Read-only.</summary>
Task<(IReadOnlyList<Category> Rows, int Total)> ListPagedAsync(
string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default);
/// <summary>A category for display, not tracked — use <see cref="IRepository{T}.GetByIdAsync"/> to change one.</summary>
Task<Category?> GetForReadAsync(int categoryId, CancellationToken ct = default);
Task<bool> ExistsAsync(int categoryId, CancellationToken ct = default);
/// <summary>True when another category already has this name (case-insensitive), ignoring <paramref name="excludeCategoryId"/>.</summary>
Task<bool> NameExistsAsync(string name, int? excludeCategoryId = null, CancellationToken ct = default);
// --- Subcategories ---------------------------------------------------------------------------
/// <summary>One page of a category's subcategories, name-ordered, with the search / status filters. Read-only.</summary>
Task<(IReadOnlyList<SubCategory> Rows, int Total)> ListSubCategoriesPagedAsync(
int categoryId, string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default);
/// <summary>A subcategory for display, not tracked.</summary>
Task<SubCategory?> GetSubCategoryForReadAsync(int subCategoryId, CancellationToken ct = default);
/// <summary>A subcategory tracked for changes.</summary>
Task<SubCategory?> GetSubCategoryForUpdateAsync(int subCategoryId, CancellationToken ct = default);
/// <summary>True when the category already has a subcategory with this name (case-insensitive), ignoring <paramref name="excludeSubCategoryId"/>.</summary>
Task<bool> SubCategoryNameExistsAsync(int categoryId, string name, int? excludeSubCategoryId = null, CancellationToken ct = default);
Task AddSubCategoryAsync(SubCategory subCategory, CancellationToken ct = default);
}
@@ -0,0 +1,53 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Stock-count data access (FR-STK-08). Owns every query <see cref="Services.CountService"/> 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.
/// </summary>
public interface ICountRepository : IRepository<StockCount>
{
// --- Counts ----------------------------------------------------------------------------------
/// <summary>One page of counts (newest first) with each count's line total, after the list filters. Read-only.</summary>
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);
/// <summary>A count with its lines (and their batch / serial), not tracked.</summary>
Task<StockCount?> GetForReadAsync(int countId, CancellationToken ct = default);
/// <summary>A count with its lines (and their batch / serial), tracked so changes are saved.</summary>
Task<StockCount?> GetForUpdateAsync(int countId, CancellationToken ct = default);
// --- Lookups ---------------------------------------------------------------------------------
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default);
/// <summary>An item, not tracked; throws when it doesn't exist (callers check <see cref="ItemExistsAsync"/> first).</summary>
Task<Item> GetItemAsync(int itemId, CancellationToken ct = default);
/// <summary>The Adjustment-context reason code with this code, or null when it isn't configured.</summary>
Task<ReasonCode?> GetAdjustmentReasonAsync(string code, CancellationToken ct = default);
// --- Stock snapshot --------------------------------------------------------------------------
/// <summary>Every batch of the item.</summary>
Task<IReadOnlyList<Batch>> ListBatchesAsync(int itemId, CancellationToken ct = default);
/// <summary>Quantity left in the item's FIFO layers at the warehouse for one batch — or, with <paramref name="batchId"/> null, stock with no batch.</summary>
Task<decimal> SumBatchQtyAsync(int itemId, int warehouseId, int? batchId, CancellationToken ct = default);
/// <summary>Quantity left in the item's FIFO layers at the warehouse that carry no serial (pre-tracking stock).</summary>
Task<decimal> SumUnserializedQtyAsync(int itemId, int warehouseId, CancellationToken ct = default);
/// <summary>The item's serials that should be at the warehouse (InStock or Damaged), by serial number.</summary>
Task<IReadOnlyList<Serial>> ListCountableSerialsAsync(int itemId, int warehouseId, CancellationToken ct = default);
/// <summary>True when the serial still has stock in an open FIFO layer at the warehouse.</summary>
Task<bool> HasOpenSerialLayerAsync(int itemId, int warehouseId, int serialId, CancellationToken ct = default);
}
@@ -0,0 +1,47 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>Filters for <see cref="ICustomerRepository.ListPagedAsync"/>.</summary>
/// <param name="IncludeCashCustomer">False keeps the shared walk-in "Cash Customer" out of the list.</param>
/// <param name="BranchId">Only customers created under this branch (the cash customer is shared, so it is never filtered out by branch).</param>
/// <param name="CashCustomerCode">Code of the system cash-customer row, recognised alongside SalesConfig's CashCustomerId.</param>
public sealed record CustomerListCriteria(
string? Search, EntityStatus? Status, CustomerType? CustomerType,
bool IncludeCashCustomer, int? BranchId, string CashCustomerCode);
/// <summary>Which optional customer fields Sales Configuration has switched on.</summary>
public sealed record CustomerFieldSwitches(bool Phone, bool Email, bool Address, bool City, bool Country, bool CreditLimit, bool CreditDays);
/// <summary>A customer's receivable position: unpaid balance and count of Posted invoices, and their latest payment.</summary>
public sealed record CustomerReceivable(decimal OutstandingAmount, int OutstandingInvoiceCount, DateTime? LastPaymentDate);
/// <summary>
/// Customer data access. Owns every query <see cref="Services.CustomerService"/> 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.
/// </summary>
public interface ICustomerRepository : IRepository<Customer>
{
/// <summary>One page of customers, name-ordered, after <paramref name="criteria"/>. Read-only.</summary>
Task<(IReadOnlyList<Customer> Rows, int Total)> ListPagedAsync(
CustomerListCriteria criteria, int skip, int take, CancellationToken ct = default);
/// <summary>Receivable position for each of these customers; customers with nothing owed / paid are absent.</summary>
Task<IReadOnlyDictionary<int, CustomerReceivable>> GetReceivablesAsync(IReadOnlyCollection<int> customerIds, CancellationToken ct = default);
/// <summary>A customer for display, not tracked — use <see cref="IRepository{T}.GetByIdAsync"/> to change one.</summary>
Task<Customer?> GetForReadAsync(int customerId, CancellationToken ct = default);
/// <summary>True when another customer already has this code (case-insensitive), ignoring <paramref name="excludeCustomerId"/>.</summary>
Task<bool> CodeExistsAsync(string code, int? excludeCustomerId = null, CancellationToken ct = default);
/// <summary>True when another customer already has this email (case-insensitive), ignoring <paramref name="excludeCustomerId"/>.</summary>
Task<bool> EmailExistsAsync(string email, int? excludeCustomerId = null, CancellationToken ct = default);
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
/// <summary>The optional-field switches from Sales Configuration, or null when its row is missing.</summary>
Task<CustomerFieldSwitches?> GetFieldSwitchesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,50 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>A Posted sales slip in the window (slim projection).</summary>
public sealed record DayEndSlipRow(int SalesSlipId, string SlipNo, DateTime SlipDate, string CustomerName, decimal GrandTotal, decimal PaidAmount, decimal CashAmount, decimal CardAmount);
/// <summary>A Posted bundle sale in the window (slim projection). Bundle sales are always fully paid.</summary>
public sealed record DayEndBundleRow(int BundleSaleId, string BundleNo, DateTime BundleDate, string CustomerName, decimal GrandTotal, decimal CashAmount, decimal CardAmount);
/// <summary>A Posted sales invoice in the window (slim projection).</summary>
public sealed record DayEndInvoiceRow(int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, string CustomerName, decimal NetPayable, decimal PaidAmount);
/// <summary>A payment on a Posted invoice, with that invoice's date.</summary>
public sealed record DayEndPaymentRow(int SalesInvoiceId, DateTime InvoiceDate, DateTime PaymentDate, SalesPaymentMethod PaymentMethod, decimal Amount);
/// <summary>A Posted expense, with its category and title names.</summary>
public sealed record DayEndExpenseRow(int ExpenseId, string DocNo, DateOnly ExpenseDate, string CategoryName, string TitleName, string? Payee, decimal Amount, decimal PaidAmount);
/// <summary>A Posted bank deposit.</summary>
public sealed record DayEndDepositRow(int BankDepositId, string DocNo, string ToBankAccountName, string? ToBankName, string? ToAccountNumber, string? DepositSlipNo, DateOnly DepositDate, decimal Amount);
/// <summary>
/// Read-only queries behind <see cref="Services.DayEndReportService"/>. Every method returns slim rows; the service
/// buckets them into local business days and works out the totals and profit (00-CORE §4).
/// <para>Instant windows are UTC <c>[start, end)</c>; date ranges are inclusive. <c>branchId</c> null = every branch.</para>
/// </summary>
public interface IDayEndReportRepository
{
Task<IReadOnlyList<DayEndSlipRow>> ListPostedSlipsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default);
Task<IReadOnlyList<DayEndBundleRow>> ListPostedBundlesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default);
Task<IReadOnlyList<DayEndInvoiceRow>> ListPostedInvoicesAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default);
/// <summary>
/// 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).
/// </summary>
Task<IReadOnlyList<DayEndPaymentRow>> ListInvoicePaymentsAsync(DateTime start, DateTime end, int? branchId, CancellationToken ct = default);
/// <summary>Sum of the stock ledger's Out value per document of <paramref name="docType"/> — the FIFO cost each sale consumed.</summary>
Task<IReadOnlyDictionary<int, decimal>> GetCostByDocAsync(string docType, IEnumerable<int> docIds, CancellationToken ct = default);
/// <summary>Posted expenses dated <paramref name="from"/>–<paramref name="to"/>, by expense id.</summary>
Task<IReadOnlyList<DayEndExpenseRow>> ListPostedExpensesAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct = default);
/// <summary>Posted bank deposits for one business date, by deposit id.</summary>
Task<IReadOnlyList<DayEndDepositRow>> ListPostedDepositsAsync(DateOnly businessDate, int? branchId, CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Expense payment data access (docs/EXPENSE_MODULE_PLAN.md §4). Owns the queries
/// <see cref="Services.ExpensePaymentService"/> runs itself; loading the expense being paid stays with
/// <see cref="Services.ExpenseService.LoadAsync"/>, which the payment service shares.
/// </summary>
public interface IExpensePaymentRepository : IRepository<ExpensePayment>
{
Task<bool> ExpenseExistsAsync(int expenseId, CancellationToken ct = default);
/// <summary>The expense's payment rows, newest first. Read-only.</summary>
Task<IReadOnlyList<ExpensePayment>> ListForExpenseAsync(int expenseId, CancellationToken ct = default);
}
@@ -0,0 +1,46 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Expenses;
namespace ERPCore.Repositories.Interfaces;
/// <summary>A Posted expense in the summary window, with its category and title names.</summary>
public sealed record ExpenseSummaryRow(
int ExpenseCategoryId, string CategoryName, int CategorySort,
int ExpenseTitleId, string TitleName, decimal Amount, decimal PaidAmount, decimal BalanceAmount);
/// <summary>
/// Expense data access (docs/EXPENSE_MODULE_PLAN.md §4). Owns every query <see cref="Services.ExpenseService"/>
/// runs, so the service holds only the business rules (draft-only edits, title/category checks, GL accrual and
/// reversal) — 00-CORE §4.
/// </summary>
public interface IExpenseRepository : IRepository<Expense>
{
/// <summary>
/// One page of expenses (with category and title), newest expense date first, after the search and
/// <paramref name="filter"/>. "Overdue" means Posted, with a balance, due before <paramref name="today"/>. Read-only.
/// </summary>
Task<(IReadOnlyList<Expense> Rows, int Total)> ListPagedAsync(
string? search, ExpenseListFilter filter, DateOnly today, int skip, int take, CancellationToken ct = default);
/// <summary>An expense with its category, title, vendor and payments; tracked or read-only.</summary>
Task<Expense?> GetWithDetailsAsync(int expenseId, bool tracking, CancellationToken ct = default);
/// <summary>
/// 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.
/// </summary>
Task<ExpenseTitle?> GetTitleWithCategoryAsync(int expenseTitleId, CancellationToken ct = default);
/// <summary>The vendor's name, or null when it doesn't exist.</summary>
Task<string?> GetVendorNameAsync(int vendorId, CancellationToken ct = default);
// --- Summary report --------------------------------------------------------------------------
/// <summary>Posted expenses dated <paramref name="from"/>–<paramref name="to"/> (inclusive).</summary>
Task<IReadOnlyList<ExpenseSummaryRow>> ListPostedInRangeAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
/// <summary>Every Posted expense with a balance still outstanding, as (balance, due date).</summary>
Task<IReadOnlyList<(decimal BalanceAmount, DateOnly? DueDate)>> ListOutstandingAsync(CancellationToken ct = default);
Task<int> CountDraftsAsync(CancellationToken ct = default);
}
@@ -0,0 +1,37 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Expense category + title data access. Owns every query <see cref="Services.ExpenseSetupService"/> runs,
/// so the service holds only the setup rules (system rows keep their names, unique names, next sort order) —
/// 00-CORE §4.
/// </summary>
public interface IExpenseSetupRepository : IRepository<ExpenseCategory>
{
// --- Categories ------------------------------------------------------------------------------
/// <summary>Categories with their titles, by sort order then name; only Active categories unless <paramref name="includeInactive"/>. Read-only.</summary>
Task<IReadOnlyList<ExpenseCategory>> ListWithTitlesAsync(bool includeInactive, CancellationToken ct = default);
/// <summary>A category with its titles, tracked so changes are saved.</summary>
Task<ExpenseCategory?> GetWithTitlesForUpdateAsync(int expenseCategoryId, CancellationToken ct = default);
Task<bool> ExistsAsync(int expenseCategoryId, CancellationToken ct = default);
/// <summary>True when another category already has exactly this name.</summary>
Task<bool> CategoryNameExistsAsync(string name, int excludeCategoryId, CancellationToken ct = default);
// --- Titles ----------------------------------------------------------------------------------
/// <summary>A title, tracked so changes are saved.</summary>
Task<ExpenseTitle?> GetTitleForUpdateAsync(int expenseTitleId, CancellationToken ct = default);
/// <summary>True when the category already has a title with exactly this name, ignoring <paramref name="excludeTitleId"/>.</summary>
Task<bool> TitleNameExistsAsync(int expenseCategoryId, string name, int? excludeTitleId, CancellationToken ct = default);
/// <summary>The highest title sort order in the category, or 0 when it has none.</summary>
Task<int> MaxTitleSortOrderAsync(int expenseCategoryId, CancellationToken ct = default);
Task AddTitleAsync(ExpenseTitle title, CancellationToken ct = default);
}
@@ -0,0 +1,97 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>Filters for <see cref="IGrnRepository.ListPagedAsync"/>. Created bounds are UTC: <c>CreatedFrom</c> inclusive, <c>CreatedBefore</c> exclusive.</summary>
public sealed record GrnListCriteria(
string? Search, GrnStatus? Status, int? PoId, int? VendorId, int? WarehouseId, int? BranchId,
DateTime? CreatedFrom, DateTime? CreatedBefore, string? Sort);
/// <summary>One GRN list row (slim projection) with its line count, total and linked purchase invoice.</summary>
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);
/// <summary>
/// Goods-receipt data access. Owns every query <see cref="Services.GrnService"/> 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.
/// </summary>
public interface IGrnRepository : IRepository<Grn>
{
// --- GRNs ------------------------------------------------------------------------------------
/// <summary>
/// One page of GRNs after <paramref name="criteria"/>, sorted server-side so paging stays correct:
/// <c>created</c> / <c>vendor</c> / <c>total</c> / <c>status</c>, a leading <c>-</c> 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.
/// </summary>
Task<(IReadOnlyList<GrnListRow> Rows, int Total)> ListPagedAsync(GrnListCriteria criteria, int skip, int take, CancellationToken ct = default);
/// <summary>A GRN with its lines (warranty numbers, serials, batch) and purchase invoice, not tracked.</summary>
Task<Grn?> GetWithDetailsAsync(int grnId, CancellationToken ct = default);
/// <summary>A GRN with its lines and their captured serials, tracked — for confirming.</summary>
Task<Grn?> GetForConfirmAsync(int grnId, CancellationToken ct = default);
/// <summary>A GRN with its lines, tracked — for releasing holds.</summary>
Task<Grn?> GetWithLinesForUpdateAsync(int grnId, CancellationToken ct = default);
// --- Purchase orders -------------------------------------------------------------------------
/// <summary>A PO with its lines, not tracked; null when it doesn't exist.</summary>
Task<PurchaseOrder?> GetPoWithLinesAsync(int poId, CancellationToken ct = default);
/// <summary>A PO with its lines, tracked so its status can be updated.</summary>
Task<PurchaseOrder?> GetPoWithLinesForUpdateAsync(int poId, CancellationToken ct = default);
/// <summary>A PO line, tracked so its received quantity can be updated.</summary>
Task<PoLine?> GetPoLineForUpdateAsync(int poLineId, CancellationToken ct = default);
Task<PurchaseOrderStatus?> GetPoStatusAsync(int poId, CancellationToken ct = default);
/// <summary>Doc no. of each of these POs.</summary>
Task<IReadOnlyDictionary<int, string>> GetPoDocNosAsync(IReadOnlyCollection<int> poIds, CancellationToken ct = default);
// --- Lookups ---------------------------------------------------------------------------------
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
Task<bool> VendorExistsAsync(int vendorId, CancellationToken ct = default);
/// <summary>Each of these vendors, by id.</summary>
Task<IReadOnlyDictionary<int, Vendor>> GetVendorsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken ct = default);
/// <summary>An item, not tracked; null when it doesn't exist.</summary>
Task<Item?> GetItemAsync(int itemId, CancellationToken ct = default);
Task<bool> BinIsInWarehouseAsync(int binId, int warehouseId, CancellationToken ct = default);
/// <summary>The tax rate's percentage; throws when it doesn't exist (an item only points at real rates).</summary>
Task<decimal> GetTaxPercentageAsync(int taxRateId, CancellationToken ct = default);
// --- Batches & serials -----------------------------------------------------------------------
/// <summary>The item's batch with this number, tracked; null when there is none yet.</summary>
Task<Batch?> FindBatchAsync(int itemId, string batchNo, CancellationToken ct = default);
Task AddBatchAsync(Batch batch, CancellationToken ct = default);
/// <summary>Up to <paramref name="take"/> of these serial numbers that already exist for the item.</summary>
Task<IReadOnlyList<string>> ListTakenSerialsAsync(int itemId, IReadOnlyCollection<string> serialNos, int take, CancellationToken ct = default);
Task AddSerialAsync(Serial serial, CancellationToken ct = default);
Task AddSerialMovementAsync(SerialMovement movement, CancellationToken ct = default);
// --- Confirm result (idempotent replay) ------------------------------------------------------
/// <summary>The FIFO layers created for these GRN lines.</summary>
Task<IReadOnlyList<StockLayer>> ListLayersForLinesAsync(IReadOnlyCollection<int> grnLineIds, CancellationToken ct = default);
/// <summary>Ids of the GRN's inbound stock-ledger rows.</summary>
Task<IReadOnlyList<int>> ListInboundLedgerIdsAsync(int grnId, CancellationToken ct = default);
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Item photo data access. Owns every query <see cref="Services.ItemImageService"/> 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.
/// </summary>
public interface IItemImageRepository : IRepository<ItemImage>
{
Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default);
/// <summary>The item's images in display order (sort order, then id). Read-only.</summary>
Task<IReadOnlyList<ItemImage>> ListForItemAsync(int itemId, CancellationToken ct = default);
/// <summary>The sort order of each of the item's images (one per image), for the count cap and the next slot.</summary>
Task<IReadOnlyList<int>> ListSortOrdersAsync(int itemId, CancellationToken ct = default);
/// <summary>The image, only when it belongs to this item; tracked when it is to be changed or removed.</summary>
Task<ItemImage?> GetForItemAsync(int itemId, int itemImageId, bool tracking, CancellationToken ct = default);
}
@@ -0,0 +1,102 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
namespace ERPCore.Repositories.Interfaces;
/// <summary>Filters shared by the item list and the item overview. <c>Search</c> matches SKU or name.</summary>
public sealed record ItemListCriteria(
string? Search, EntityStatus? Status, int? CategoryId, int? SubCategoryId, int? BrandId,
TrackingMode? TrackingMode, StockNature? StockNature = null, ItemStockFilter? Stock = null);
/// <summary>A confirmed GRN line for the item: when it was posted and from which vendor.</summary>
public sealed record ItemGrnPurchaseRow(int ItemId, DateTime PostedAt, int VendorId, string VendorName);
/// <summary>One confirmed purchase of the item, newest first, for the purchase-price history.</summary>
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);
/// <summary>
/// Item master data access. Owns every query <see cref="Services.ItemService"/> 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.
/// </summary>
public interface IItemRepository : IRepository<Item>
{
// --- Lists -----------------------------------------------------------------------------------
/// <summary>One page of items (with tax rate) by SKU, after <paramref name="criteria"/>. Read-only.</summary>
Task<(IReadOnlyList<Item> Rows, int Total)> ListPagedAsync(ItemListCriteria criteria, int skip, int take, CancellationToken ct = default);
/// <summary>
/// One page of items (with reorder settings) by SKU, after <paramref name="criteria"/> — including the stock-state
/// filter, applied as a subquery before paging so totals and pages stay correct. Read-only.
/// </summary>
Task<(IReadOnlyList<Item> Rows, int Total)> ListOverviewPagedAsync(ItemListCriteria criteria, int skip, int take, CancellationToken ct = default);
/// <summary>Current FIFO stock (qty, value) per item over every open layer and warehouse (negative = sold beyond stock).</summary>
Task<IReadOnlyDictionary<int, (decimal Qty, decimal Value)>> GetStockByItemAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
/// <summary>On-hand per (item, warehouse).</summary>
Task<IReadOnlyDictionary<(int ItemId, int WarehouseId), decimal>> GetOnHandByItemWarehouseAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
/// <summary>Every confirmed GRN line of these items, with its post time and vendor.</summary>
Task<IReadOnlyList<ItemGrnPurchaseRow>> ListConfirmedPurchasesAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
/// <summary>Qty that left stock on these document types since <paramref name="since"/>, per item (stock ledger Out rows).</summary>
Task<IReadOnlyDictionary<int, decimal>> GetQtyOutSinceAsync(
IReadOnlyCollection<int> itemIds, DateTime since, IReadOnlyCollection<string> docTypes, CancellationToken ct = default);
/// <summary>The item's latest confirmed purchases, newest first.</summary>
Task<IReadOnlyList<ItemPurchasePriceRow>> ListPurchasePricesAsync(int itemId, int take, CancellationToken ct = default);
// --- Single item -----------------------------------------------------------------------------
Task<bool> ExistsAsync(int itemId, CancellationToken ct = default);
/// <summary>An item with its reorder settings and tax rate; tracked when it is to be changed.</summary>
Task<Item?> GetWithDetailsAsync(int itemId, bool tracking, CancellationToken ct = default);
/// <summary>True when another item already has this SKU, ignoring <paramref name="excludeItemId"/>.</summary>
Task<bool> SkuExistsAsync(string sku, int? excludeItemId = null, CancellationToken ct = default);
/// <summary>True when another item already has this barcode, ignoring <paramref name="excludeItemId"/>.</summary>
Task<bool> BarcodeExistsAsync(string barcode, int? excludeItemId = null, CancellationToken ct = default);
/// <summary>
/// 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.
/// </summary>
Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct = default);
/// <summary>Settings → Approvals price-adjustment levels, or null when that row is missing.</summary>
Task<int?> GetPriceAdjustmentLevelsAsync(CancellationToken ct = default);
// --- Referenced masters ----------------------------------------------------------------------
Task<bool> CategoryExistsAsync(int categoryId, CancellationToken ct = default);
Task<SubCategory?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default);
Task<Brand?> GetBrandAsync(int brandId, CancellationToken ct = default);
Task<bool> UomExistsAsync(int uomId, CancellationToken ct = default);
Task<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default);
Task<TaxRate?> GetTaxRateAsync(int taxRateId, CancellationToken ct = default);
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
// --- Scanning (each result carries its Item) -------------------------------------------------
/// <summary>An item whose barcode or SKU is <paramref name="code"/>.</summary>
Task<Item?> FindItemByCodeAsync(string code, CancellationToken ct = default);
/// <summary>A batch whose barcode or batch no. is <paramref name="code"/>, limited to one item when <paramref name="itemId"/> is given.</summary>
Task<Batch?> FindBatchByCodeAsync(string code, int? itemId = null, CancellationToken ct = default);
/// <summary>A serial whose barcode or serial no. is <paramref name="code"/>, limited to one item when <paramref name="itemId"/> is given.</summary>
Task<Serial?> FindSerialByCodeAsync(string code, int? itemId = null, CancellationToken ct = default);
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Item type data access (FR-MD-10). Owns every query <see cref="Services.ItemTypeService"/> runs, so the service
/// holds only the business rules (name uniqueness, concurrency) — 00-CORE §4.
/// </summary>
public interface IItemTypeRepository : IRepository<ItemType>
{
/// <summary>One page of item types, name-ordered, optionally filtered by a name search and status. Read-only.</summary>
Task<(IReadOnlyList<ItemType> Rows, int Total)> ListPagedAsync(
string? search, EntityStatus? status, int skip, int take, CancellationToken ct = default);
/// <summary>An item type for display, not tracked — use <see cref="IRepository{T}.GetByIdAsync"/> to change one.</summary>
Task<ItemType?> GetForReadAsync(int itemTypeId, CancellationToken ct = default);
/// <summary>True when another item type already has this name (case-insensitive), ignoring <paramref name="excludeItemTypeId"/>.</summary>
Task<bool> NameExistsAsync(string name, int? excludeItemTypeId = null, CancellationToken ct = default);
}
@@ -0,0 +1,18 @@
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Document-number data access behind <see cref="Services.NumberSequenceService"/>: the branch code that goes into
/// a number, and the atomic counter bump itself. The service owns the number format — 00-CORE §4.
/// </summary>
public interface INumberSequenceRepository
{
/// <summary>The branch's code, or null when the branch doesn't exist.</summary>
Task<string?> GetBranchCodeAsync(int branchId, CancellationToken ct = default);
/// <summary>
/// Atomically issues the next number for (<paramref name="docType"/>, <paramref name="branchId"/>,
/// <paramref name="year"/>) — 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.
/// </summary>
Task<long> IncrementAsync(string docType, int branchId, int year, CancellationToken ct = default);
}
@@ -0,0 +1,30 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Opening-stock data access. Owns every query <see cref="Services.OpeningStockService"/> runs and the rows it
/// writes directly (stock ledger, journal stubs) — the service keeps the opening rules and the GL posting — 00-CORE §4.
/// </summary>
public interface IOpeningStockRepository : IRepository<OpeningBalanceRun>
{
/// <summary>Every opening-stock run (newest first) with its line count. Read-only.</summary>
Task<IReadOnlyList<(OpeningBalanceRun Run, int LineCount)>> ListStockRunsAsync(CancellationToken ct = default);
/// <summary>An opening-stock run with its lines (and their item / warehouse), not tracked; null when not found or not a stock run.</summary>
Task<OpeningBalanceRun?> GetStockRunAsync(int runId, CancellationToken ct = default);
/// <summary>Each of these items, by id. Read-only.</summary>
Task<IReadOnlyDictionary<int, Item>> GetItemsAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
/// <summary>Each of these warehouses, by id. Read-only.</summary>
Task<IReadOnlyDictionary<int, Warehouse>> GetWarehousesAsync(IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default);
/// <summary>The distinct (item, warehouse) pairs among these that already have any stock-ledger movement.</summary>
Task<IReadOnlyList<(int ItemId, int WarehouseId)>> ListMovedPairsAsync(
IReadOnlyCollection<int> itemIds, IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default);
Task AddLedgerAsync(StockLedger row, CancellationToken ct = default);
Task AddJournalStubAsync(JournalEntryStub stub, CancellationToken ct = default);
}
@@ -0,0 +1,47 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>One price-adjustment list row (slim projection) with its line count and creator's name.</summary>
public sealed record PriceAdjustmentListRow(
int PriceAdjustmentId, string DocNo, string Reason, PriceAdjustmentStatus Status, int RequiredApprovals,
int LineCount, int CreatedBy, string? CreatorName, DateTime CreatedAt, DateTime? AppliedAt);
/// <summary>
/// Price-adjustment data access (docs/DUAL_APPROVAL_PLAN.md §6). Owns every query
/// <see cref="Services.PriceAdjustmentService"/> 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.
/// </summary>
public interface IPriceAdjustmentRepository : IRepository<PriceAdjustment>
{
/// <summary>One page of requests, newest first, optionally filtered by doc no. / reason, status and an item on them. Read-only.</summary>
Task<(IReadOnlyList<PriceAdjustmentListRow> Rows, int Total)> ListPagedAsync(
string? search, PriceAdjustmentStatus? status, int? itemId, int skip, int take, CancellationToken ct = default);
/// <summary>A request with its lines (and their items) and creator, not tracked.</summary>
Task<PriceAdjustment?> GetWithDetailsAsync(int priceAdjustmentId, CancellationToken ct = default);
/// <summary>A request with its lines, tracked — for deciding on it.</summary>
Task<PriceAdjustment?> GetForDecisionAsync(int priceAdjustmentId, CancellationToken ct = default);
/// <summary>Each of these items, by id, tracked so their prices can be changed.</summary>
Task<IReadOnlyDictionary<int, Item>> GetItemsForUpdateAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
/// <summary>For each of these items that sits on a pending (level 1 or 2) request: the item and that request's doc no.</summary>
Task<IReadOnlyList<(int ItemId, string DocNo)>> ListPendingRequestsForItemsAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
Task AddHistoryAsync(ItemPriceHistory history, CancellationToken ct = default);
/// <summary>The item's applied price changes, newest first. Read-only.</summary>
Task<IReadOnlyList<ItemPriceHistory>> ListItemHistoryAsync(int itemId, CancellationToken ct = default);
/// <summary>Display name of each of these users.</summary>
Task<IReadOnlyDictionary<int, string>> GetUserNamesAsync(IReadOnlyCollection<int> userIds, CancellationToken ct = default);
/// <summary>Doc no. of each of these requests.</summary>
Task<IReadOnlyDictionary<int, string>> GetDocNosAsync(IReadOnlyCollection<int> priceAdjustmentIds, CancellationToken ct = default);
/// <summary>Settings → Approvals price-adjustment levels, or null when that row is missing.</summary>
Task<int?> GetPriceAdjustmentLevelsAsync(CancellationToken ct = default);
}
@@ -0,0 +1,13 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Procurement configuration data access — the singleton row seeded by DataSeeder. Owns the queries
/// <see cref="Services.ProcurementConfigService"/> runs (00-CORE §4).
/// </summary>
public interface IProcurementConfigRepository : IRepository<ProcurementConfig>
{
/// <summary>The singleton row, not tracked; null when it hasn't been seeded. Use <see cref="IRepository{T}.GetByIdAsync"/> to change it.</summary>
Task<ProcurementConfig?> GetForReadAsync(CancellationToken ct = default);
}
@@ -0,0 +1,13 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Product configuration data access (FR-MD-11) — the singleton row seeded by DataSeeder. Owns the queries
/// <see cref="Services.ProductConfigService"/> runs (00-CORE §4).
/// </summary>
public interface IProductConfigRepository : IRepository<ProductConfig>
{
/// <summary>The singleton row, not tracked; null when it hasn't been seeded. Use <see cref="IRepository{T}.GetByIdAsync"/> to change it.</summary>
Task<ProductConfig?> GetForReadAsync(CancellationToken ct = default);
}
@@ -0,0 +1,18 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Vendor payment data access. Owns every query <see cref="Services.PurchaseInvoicePaymentService"/> runs, so the
/// service holds only the payment rules and the GL posting — 00-CORE §4.
/// </summary>
public interface IPurchaseInvoicePaymentRepository : IRepository<PurchaseInvoicePayment>
{
Task<bool> InvoiceExistsAsync(int purchaseInvoiceId, CancellationToken ct = default);
/// <summary>The purchase invoice, tracked so its paid / balance amounts can be updated.</summary>
Task<PurchaseInvoice?> GetInvoiceForUpdateAsync(int purchaseInvoiceId, CancellationToken ct = default);
/// <summary>The invoice's payments, newest first. Read-only.</summary>
Task<IReadOnlyList<PurchaseInvoicePayment>> ListForInvoiceAsync(int purchaseInvoiceId, CancellationToken ct = default);
}
@@ -0,0 +1,36 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>A confirmed, not-yet-invoiced GRN of a vendor, with its line totals.</summary>
public sealed record EligibleGrnRow(
int GrnId, string DocNo, DateTime ReceivedAt, int WarehouseId, int LineCount,
decimal Subtotal, decimal VatTotal, decimal GrandTotal);
/// <summary>
/// Purchase invoice data access. Owns every query <see cref="Services.PurchaseInvoiceService"/> runs — invoices,
/// the GRNs they consolidate, and the vendor — so the service holds only the invoicing rules and the GL posting
/// (00-CORE §4).
/// </summary>
public interface IPurchaseInvoiceRepository : IRepository<PurchaseInvoice>
{
/// <summary>One page of invoices (with lines), newest first, optionally filtered by doc no. / vendor invoice no. / vendor name, status and vendor. Read-only.</summary>
Task<(IReadOnlyList<PurchaseInvoice> Rows, int Total)> ListPagedAsync(
string? search, PurchaseInvoiceStatus? status, int? vendorId, int skip, int take, CancellationToken ct = default);
/// <summary>An invoice with its lines (and their GRNs); tracked when it is to be changed.</summary>
Task<PurchaseInvoice?> GetWithLinesAsync(int purchaseInvoiceId, bool tracking, CancellationToken ct = default);
/// <summary>The vendor, not tracked; null when it doesn't exist.</summary>
Task<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default);
/// <summary>True when a non-cancelled invoice of this vendor already carries this vendor invoice no., ignoring <paramref name="excludePurchaseInvoiceId"/>.</summary>
Task<bool> VendorInvoiceNoExistsAsync(int vendorId, string vendorInvoiceNo, int? excludePurchaseInvoiceId, CancellationToken ct = default);
/// <summary>These GRNs with their lines, tracked so they can be linked to / freed from an invoice.</summary>
Task<IReadOnlyList<Grn>> ListGrnsForInvoicingAsync(IReadOnlyCollection<int> grnIds, CancellationToken ct = default);
/// <summary>The vendor's confirmed GRNs not yet on an invoice, oldest receipt first.</summary>
Task<IReadOnlyList<EligibleGrnRow>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default);
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Repositories.Interfaces;
/// <summary>Filters for <see cref="IPurchaseOrderRepository.ListPagedAsync"/>. Created bounds are UTC: <c>CreatedFrom</c> inclusive, <c>CreatedBefore</c> exclusive.</summary>
public sealed record PurchaseOrderListCriteria(
string? Search, PurchaseOrderStatus? Status, int? VendorId, DateTime? CreatedFrom, DateTime? CreatedBefore, string? Sort);
/// <summary>
/// Purchase-order data access. Owns every query <see cref="Services.PurchaseOrderService"/> 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.
/// </summary>
public interface IPurchaseOrderRepository : IRepository<PurchaseOrder>
{
/// <summary>
/// One page of POs (with lines) after <paramref name="criteria"/>, sorted server-side so paging stays correct:
/// <c>created</c> / <c>vendor</c> / <c>total</c> / <c>status</c>, a leading <c>-</c> 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.
/// </summary>
Task<(IReadOnlyList<PurchaseOrder> Rows, int Total)> ListPagedAsync(PurchaseOrderListCriteria criteria, int skip, int take, CancellationToken ct = default);
/// <summary>A PO with its lines; tracked when it is to be changed.</summary>
Task<PurchaseOrder?> GetWithLinesAsync(int poId, bool tracking, CancellationToken ct = default);
/// <summary>The vendor, not tracked; null when it doesn't exist.</summary>
Task<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default);
/// <summary>Each of these vendors, by id. Read-only.</summary>
Task<IReadOnlyDictionary<int, Vendor>> GetVendorsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken ct = default);
Task<bool> RequisitionExistsAsync(int requisitionId, CancellationToken ct = default);
/// <summary>Which of these item ids exist.</summary>
Task<IReadOnlyList<int>> ListExistingItemIdsAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default);
/// <summary>Which of these warehouse ids exist.</summary>
Task<IReadOnlyList<int>> ListExistingWarehouseIdsAsync(IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default);
/// <summary>Which of these warehouses belong to a branch other than <paramref name="branchId"/>.</summary>
Task<IReadOnlyList<int>> ListWarehousesOutsideBranchAsync(IReadOnlyCollection<int> warehouseIds, int branchId, CancellationToken ct = default);
}
@@ -0,0 +1,45 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Purchase-return data access (FR-PROC-08). Owns every query <see cref="Services.PurchaseReturnService"/> 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).
/// </summary>
public interface IPurchaseReturnRepository : IRepository<PurchaseReturn>
{
/// <summary>One page of returns (newest first) with each one's line count, optionally filtered by doc no., vendor and warehouse. Read-only.</summary>
Task<(IReadOnlyList<(PurchaseReturn Return, int LineCount)> Rows, int Total)> ListPagedAsync(
string? search, int? vendorId, int? warehouseId, int skip, int take, CancellationToken ct = default);
/// <summary>A return with its lines, not tracked.</summary>
Task<PurchaseReturn?> GetWithLinesAsync(int returnId, CancellationToken ct = default);
/// <summary>The return's stock-ledger row ids, in posting order (polymorphic source-doc reference, docs/10 C.9).</summary>
Task<IReadOnlyList<int>> ListLedgerIdsAsync(int returnId, CancellationToken ct = default);
// --- References ------------------------------------------------------------------------------
Task<bool> VendorExistsAsync(int vendorId, CancellationToken ct = default);
Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default);
Task<ReasonCode?> GetReasonCodeAsync(int reasonCodeId, CancellationToken ct = default);
Task<Item?> GetItemAsync(int itemId, CancellationToken ct = default);
/// <summary>A GRN line, not tracked; null when it doesn't exist.</summary>
Task<GrnLine?> GetGrnLineAsync(int grnLineId, CancellationToken ct = default);
// --- Rejected GRN-line stock -----------------------------------------------------------------
/// <summary>Quantity still sitting in the GRN line's own FIFO layer(s).</summary>
Task<decimal> SumRemainingOnGrnLineAsync(int grnLineId, CancellationToken ct = default);
/// <summary>The GRN line's layers with stock left, oldest first, tracked so they can be consumed.</summary>
Task<List<StockLayer>> ListOpenLayersForGrnLineAsync(int grnLineId, CancellationToken ct = default);
/// <summary>These serials, tracked so their status can be moved.</summary>
Task<IReadOnlyList<Serial>> ListSerialsAsync(IReadOnlyCollection<int> serialIds, CancellationToken ct = default);
}
@@ -0,0 +1,30 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Repositories;
/// <summary>EF implementation of <see cref="IItemImageRepository"/>.</summary>
public sealed class ItemImageRepository : Repository<ItemImage>, IItemImageRepository
{
public ItemImageRepository(ErpDbContext db) : base(db) { }
public Task<bool> ItemExistsAsync(int itemId, CancellationToken ct = default)
=> Db.Set<Item>().AnyAsync(i => i.ItemId == itemId, ct);
public async Task<IReadOnlyList<ItemImage>> 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<IReadOnlyList<int>> ListSortOrdersAsync(int itemId, CancellationToken ct = default)
=> await Set.Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct);
public Task<ItemImage?> 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);
}
}
@@ -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;
/// <summary>EF implementation of <see cref="IItemRepository"/>.</summary>
public sealed class ItemRepository : Repository<Item>, IItemRepository
{
public ItemRepository(ErpDbContext db) : base(db) { }
private DbSet<StockLayer> Layers => Db.Set<StockLayer>();
// --- Lists -----------------------------------------------------------------------------------
private IQueryable<Item> 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<Item> 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<Item> 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<IReadOnlyDictionary<int, (decimal Qty, decimal Value)>> GetStockByItemAsync(
IReadOnlyCollection<int> 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<IReadOnlyDictionary<(int ItemId, int WarehouseId), decimal>> GetOnHandByItemWarehouseAsync(
IReadOnlyCollection<int> 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<IReadOnlyList<ItemGrnPurchaseRow>> ListConfirmedPurchasesAsync(
IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
{
var ids = itemIds.ToList();
return await Db.Set<GrnLine>().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<IReadOnlyDictionary<int, decimal>> GetQtyOutSinceAsync(
IReadOnlyCollection<int> itemIds, DateTime since, IReadOnlyCollection<string> docTypes, CancellationToken ct = default)
{
var ids = itemIds.ToList();
var types = docTypes.ToList();
return await Db.Set<StockLedger>().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<IReadOnlyList<ItemPurchasePriceRow>> ListPurchasePricesAsync(int itemId, int take, CancellationToken ct = default)
=> await Db.Set<GrnLine>().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<bool> ExistsAsync(int itemId, CancellationToken ct = default)
=> Set.AnyAsync(i => i.ItemId == itemId, ct);
public Task<Item?> 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<bool> SkuExistsAsync(string sku, int? excludeItemId = null, CancellationToken ct = default)
=> Set.AnyAsync(i => i.Sku == sku && (excludeItemId == null || i.ItemId != excludeItemId), ct);
public Task<bool> BarcodeExistsAsync(string barcode, int? excludeItemId = null, CancellationToken ct = default)
=> Set.AnyAsync(i => i.Barcode == barcode && (excludeItemId == null || i.ItemId != excludeItemId), ct);
public async Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct = default)
=> await Layers.AnyAsync(l => l.ItemId == itemId, ct)
|| await Db.Set<StockLedger>().AnyAsync(l => l.ItemId == itemId, ct);
public Task<int?> GetPriceAdjustmentLevelsAsync(CancellationToken ct = default)
=> Db.Set<ApprovalConfig>().AsNoTracking()
.Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
.Select(c => (int?)c.PriceAdjustmentLevels)
.FirstOrDefaultAsync(ct);
// --- Referenced masters ----------------------------------------------------------------------
public Task<bool> CategoryExistsAsync(int categoryId, CancellationToken ct = default)
=> Db.Set<Category>().AnyAsync(c => c.CategoryId == categoryId, ct);
public Task<SubCategory?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default)
=> Db.Set<SubCategory>().AsNoTracking().FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
public Task<Brand?> GetBrandAsync(int brandId, CancellationToken ct = default)
=> Db.Set<Brand>().AsNoTracking().FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
public Task<bool> UomExistsAsync(int uomId, CancellationToken ct = default)
=> Db.Set<Uom>().AnyAsync(u => u.UomId == uomId, ct);
public Task<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default)
=> Db.Set<Vendor>().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
public Task<TaxRate?> GetTaxRateAsync(int taxRateId, CancellationToken ct = default)
=> Db.Set<TaxRate>().AsNoTracking().FirstOrDefaultAsync(t => t.TaxRateId == taxRateId, ct);
public Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
=> Db.Set<Warehouse>().AnyAsync(w => w.WarehouseId == warehouseId, ct);
// --- Scanning --------------------------------------------------------------------------------
public Task<Item?> FindItemByCodeAsync(string code, CancellationToken ct = default)
=> Set.AsNoTracking().FirstOrDefaultAsync(i => i.Barcode == code || i.Sku == code, ct);
public Task<Batch?> FindBatchByCodeAsync(string code, int? itemId = null, CancellationToken ct = default)
=> Db.Set<Batch>().AsNoTracking().Include(b => b.Item)
.FirstOrDefaultAsync(b => (itemId == null || b.ItemId == itemId) && (b.Barcode == code || b.BatchNo == code), ct);
public Task<Serial?> FindSerialByCodeAsync(string code, int? itemId = null, CancellationToken ct = default)
=> Db.Set<Serial>().AsNoTracking().Include(s => s.Item)
.FirstOrDefaultAsync(s => (itemId == null || s.ItemId == itemId) && (s.Barcode == code || s.SerialNo == code), ct);
}
@@ -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;
/// <summary>EF implementation of <see cref="IItemTypeRepository"/>.</summary>
public sealed class ItemTypeRepository : Repository<ItemType>, IItemTypeRepository
{
public ItemTypeRepository(ErpDbContext db) : base(db) { }
public async Task<(IReadOnlyList<ItemType> 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<ItemType?> GetForReadAsync(int itemTypeId, CancellationToken ct = default)
=> Set.AsNoTracking().FirstOrDefaultAsync(t => t.ItemTypeId == itemTypeId, ct);
public Task<bool> 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);
}
}
@@ -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;
/// <summary>
/// EF / ADO.NET implementation of <see cref="INumberSequenceRepository"/>. The bump is a single
/// <c>INSERT … ON DUPLICATE KEY UPDATE … RETURNING</c> 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 <c>SqlQuery</c> is avoided because it
/// wraps the statement in a subquery, which is not valid for a data-modifying statement.
/// </summary>
/// <remarks>
/// MariaDB 10.5+ supports <c>RETURNING</c> 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
/// <c>LAST_INSERT_ID()</c> 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 <c>sequence_id</c> has gaps — harmless, do not "fix" it.
/// <c>ON DUPLICATE KEY UPDATE</c> 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.
/// </remarks>
public sealed class NumberSequenceRepository : INumberSequenceRepository
{
private readonly ErpDbContext _db;
public NumberSequenceRepository(ErpDbContext db) => _db = db;
public Task<string?> GetBranchCodeAsync(int branchId, CancellationToken ct = default)
=> _db.Set<Branch>().AsNoTracking()
.Where(b => b.BranchId == branchId).Select(b => (string?)b.Code).FirstOrDefaultAsync(ct);
public async Task<long> 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);
}
}
@@ -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;
/// <summary>EF implementation of <see cref="IOpeningStockRepository"/>.</summary>
public sealed class OpeningStockRepository : Repository<OpeningBalanceRun>, IOpeningStockRepository
{
public OpeningStockRepository(ErpDbContext db) : base(db) { }
public async Task<IReadOnlyList<(OpeningBalanceRun Run, int LineCount)>> 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<OpeningBalanceRun?> 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<IReadOnlyDictionary<int, Item>> GetItemsAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
{
var ids = itemIds.ToList();
return await Db.Set<Item>().AsNoTracking().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
}
public async Task<IReadOnlyDictionary<int, Warehouse>> GetWarehousesAsync(IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default)
{
var ids = warehouseIds.ToList();
return await Db.Set<Warehouse>().AsNoTracking().Where(w => ids.Contains(w.WarehouseId)).ToDictionaryAsync(w => w.WarehouseId, ct);
}
public async Task<IReadOnlyList<(int ItemId, int WarehouseId)>> ListMovedPairsAsync(
IReadOnlyCollection<int> itemIds, IReadOnlyCollection<int> warehouseIds, CancellationToken ct = default)
{
var items = itemIds.ToList();
var warehouses = warehouseIds.ToList();
return (await Db.Set<StockLedger>().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<StockLedger>().AddAsync(row, ct);
public async Task AddJournalStubAsync(JournalEntryStub stub, CancellationToken ct = default)
=> await Db.Set<JournalEntryStub>().AddAsync(stub, ct);
}
@@ -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;
/// <summary>EF implementation of <see cref="IPriceAdjustmentRepository"/>.</summary>
public sealed class PriceAdjustmentRepository : Repository<PriceAdjustment>, IPriceAdjustmentRepository
{
public PriceAdjustmentRepository(ErpDbContext db) : base(db) { }
public async Task<(IReadOnlyList<PriceAdjustmentListRow> 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<PriceAdjustment?> 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<PriceAdjustment?> GetForDecisionAsync(int priceAdjustmentId, CancellationToken ct = default)
=> Set.Include(p => p.Lines).FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct);
public async Task<IReadOnlyDictionary<int, Item>> GetItemsForUpdateAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
{
var ids = itemIds.ToList();
return await Db.Set<Item>().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
}
public async Task<IReadOnlyList<(int ItemId, string DocNo)>> ListPendingRequestsForItemsAsync(
IReadOnlyCollection<int> 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<PriceAdjustmentLine>().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<ItemPriceHistory>().AddAsync(history, ct);
public async Task<IReadOnlyList<ItemPriceHistory>> ListItemHistoryAsync(int itemId, CancellationToken ct = default)
=> await Db.Set<ItemPriceHistory>().AsNoTracking()
.Where(h => h.ItemId == itemId)
.OrderByDescending(h => h.AppliedAt).ThenByDescending(h => h.ItemPriceHistoryId)
.ToListAsync(ct);
public async Task<IReadOnlyDictionary<int, string>> GetUserNamesAsync(IReadOnlyCollection<int> userIds, CancellationToken ct = default)
{
var ids = userIds.ToList();
return await Db.Set<User>().AsNoTracking().Where(u => ids.Contains(u.UserId)).ToDictionaryAsync(u => u.UserId, u => u.DisplayName, ct);
}
public async Task<IReadOnlyDictionary<int, string>> GetDocNosAsync(IReadOnlyCollection<int> 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<int?> GetPriceAdjustmentLevelsAsync(CancellationToken ct = default)
=> Db.Set<ApprovalConfig>().AsNoTracking()
.Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId)
.Select(c => (int?)c.PriceAdjustmentLevels)
.FirstOrDefaultAsync(ct);
}
@@ -0,0 +1,15 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Repositories;
/// <summary>EF implementation of <see cref="IProcurementConfigRepository"/>.</summary>
public sealed class ProcurementConfigRepository : Repository<ProcurementConfig>, IProcurementConfigRepository
{
public ProcurementConfigRepository(ErpDbContext db) : base(db) { }
public Task<ProcurementConfig?> GetForReadAsync(CancellationToken ct = default)
=> Set.AsNoTracking().FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct);
}
@@ -0,0 +1,15 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Repositories;
/// <summary>EF implementation of <see cref="IProductConfigRepository"/>.</summary>
public sealed class ProductConfigRepository : Repository<ProductConfig>, IProductConfigRepository
{
public ProductConfigRepository(ErpDbContext db) : base(db) { }
public Task<ProductConfig?> GetForReadAsync(CancellationToken ct = default)
=> Set.AsNoTracking().FirstOrDefaultAsync(c => c.ConfigId == ProductConfig.SingletonId, ct);
}
@@ -0,0 +1,24 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Repositories;
/// <summary>EF implementation of <see cref="IPurchaseInvoicePaymentRepository"/>.</summary>
public sealed class PurchaseInvoicePaymentRepository : Repository<PurchaseInvoicePayment>, IPurchaseInvoicePaymentRepository
{
public PurchaseInvoicePaymentRepository(ErpDbContext db) : base(db) { }
public Task<bool> InvoiceExistsAsync(int purchaseInvoiceId, CancellationToken ct = default)
=> Db.Set<PurchaseInvoice>().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct);
public Task<PurchaseInvoice?> GetInvoiceForUpdateAsync(int purchaseInvoiceId, CancellationToken ct = default)
=> Db.Set<PurchaseInvoice>().FirstOrDefaultAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct);
public async Task<IReadOnlyList<PurchaseInvoicePayment>> ListForInvoiceAsync(int purchaseInvoiceId, CancellationToken ct = default)
=> await Set.AsNoTracking()
.Where(p => p.PurchaseInvoiceId == purchaseInvoiceId)
.OrderByDescending(p => p.PurchaseInvoicePaymentId)
.ToListAsync(ct);
}
@@ -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;
/// <summary>EF implementation of <see cref="IPurchaseInvoiceRepository"/>.</summary>
public sealed class PurchaseInvoiceRepository : Repository<PurchaseInvoice>, IPurchaseInvoiceRepository
{
public PurchaseInvoiceRepository(ErpDbContext db) : base(db) { }
private DbSet<Grn> Grns => Db.Set<Grn>();
public async Task<(IReadOnlyList<PurchaseInvoice> Rows, int Total)> ListPagedAsync(
string? search, PurchaseInvoiceStatus? status, int? vendorId, int skip, int take, CancellationToken ct = default)
{
IQueryable<PurchaseInvoice> 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<PurchaseInvoice?> 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<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default)
=> Db.Set<Vendor>().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
public Task<bool> 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<IReadOnlyList<Grn>> ListGrnsForInvoicingAsync(IReadOnlyCollection<int> 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<IReadOnlyList<EligibleGrnRow>> 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);
}
@@ -0,0 +1,99 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Repositories;
/// <summary>EF implementation of <see cref="IPurchaseOrderRepository"/>.</summary>
public sealed class PurchaseOrderRepository : Repository<PurchaseOrder>, IPurchaseOrderRepository
{
public PurchaseOrderRepository(ErpDbContext db) : base(db) { }
private DbSet<Vendor> Vendors => Db.Set<Vendor>();
private DbSet<Warehouse> Warehouses => Db.Set<Warehouse>();
public async Task<(IReadOnlyList<PurchaseOrder> 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<PurchaseOrder> ApplySort(IQueryable<PurchaseOrder> q, string? sort)
{
var key = (sort ?? "-created").Trim();
var desc = key.StartsWith('-');
key = key.TrimStart('-', '+').ToLowerInvariant();
IOrderedQueryable<PurchaseOrder> 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<PurchaseOrder?> 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<Vendor?> GetVendorAsync(int vendorId, CancellationToken ct = default)
=> Vendors.AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
public async Task<IReadOnlyDictionary<int, Vendor>> GetVendorsAsync(IReadOnlyCollection<int> 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<bool> RequisitionExistsAsync(int requisitionId, CancellationToken ct = default)
=> Db.Set<Requisition>().AnyAsync(r => r.RequisitionId == requisitionId, ct);
public async Task<IReadOnlyList<int>> ListExistingItemIdsAsync(IReadOnlyCollection<int> itemIds, CancellationToken ct = default)
{
var ids = itemIds.ToList();
return await Db.Set<Item>().Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct);
}
public async Task<IReadOnlyList<int>> ListExistingWarehouseIdsAsync(IReadOnlyCollection<int> 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<IReadOnlyList<int>> ListWarehousesOutsideBranchAsync(
IReadOnlyCollection<int> 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);
}
}
@@ -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;
/// <summary>EF implementation of <see cref="IPurchaseReturnRepository"/>.</summary>
public sealed class PurchaseReturnRepository : Repository<PurchaseReturn>, IPurchaseReturnRepository
{
public PurchaseReturnRepository(ErpDbContext db) : base(db) { }
private DbSet<StockLayer> Layers => Db.Set<StockLayer>();
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<PurchaseReturn?> GetWithLinesAsync(int returnId, CancellationToken ct = default)
=> Set.AsNoTracking().Include(r => r.Lines).FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
public async Task<IReadOnlyList<int>> ListLedgerIdsAsync(int returnId, CancellationToken ct = default)
=> await Db.Set<StockLedger>().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
// --- References ------------------------------------------------------------------------------
public Task<bool> VendorExistsAsync(int vendorId, CancellationToken ct = default)
=> Db.Set<Vendor>().AnyAsync(v => v.VendorId == vendorId, ct);
public Task<bool> WarehouseExistsAsync(int warehouseId, CancellationToken ct = default)
=> Db.Set<Warehouse>().AnyAsync(w => w.WarehouseId == warehouseId, ct);
public Task<ReasonCode?> GetReasonCodeAsync(int reasonCodeId, CancellationToken ct = default)
=> Db.Set<ReasonCode>().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == reasonCodeId, ct);
public Task<Item?> GetItemAsync(int itemId, CancellationToken ct = default)
=> Db.Set<Item>().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
public Task<GrnLine?> GetGrnLineAsync(int grnLineId, CancellationToken ct = default)
=> Db.Set<GrnLine>().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == grnLineId, ct);
// --- Rejected GRN-line stock -----------------------------------------------------------------
public async Task<decimal> SumRemainingOnGrnLineAsync(int grnLineId, CancellationToken ct = default)
=> await Layers.AsNoTracking().Where(l => l.GrnLineId == grnLineId).SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
public Task<List<StockLayer>> 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<IReadOnlyList<Serial>> ListSerialsAsync(IReadOnlyCollection<int> serialIds, CancellationToken ct = default)
{
var ids = serialIds.ToList();
return await Db.Set<Serial>().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct);
}
}
+36 -39
View File
@@ -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;
/// </summary>
public sealed class BrandService : IBrandService
{
private readonly IRepository<Brand> _brands;
private readonly IBrandRepository _brands;
private readonly IUnitOfWork _uow;
public BrandService(IRepository<Brand> 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<PagedResponse<BrandDto>> 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<BrandDto>.Create(rows, query.Page, query.PageSize, total);
var (rows, total) = await _brands.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct);
return PagedResponse<BrandDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<BrandDto>?> 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<BrandDto>(Map(brand), brand.RowVersion);
}
public async Task<ETagged<BrandDto>> 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<BrandDto>(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);
+120 -145
View File
@@ -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;
/// <summary>
/// Bundle sales and bundle templates. Every query goes through <see cref="IBundleSaleRepository"/>;
/// every write runs inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> (which saves and commits
/// on success), the same shape as <see cref="BankDepositService"/> — build/change inside, map after.
/// </summary>
public sealed class BundleSaleService : IBundleSaleService
{
private readonly IRepository<BundleSaleTemplate> _templates;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesDayEnd> _dayEnds;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Item> _items;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _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<BundleSale> bundles,
IRepository<BundleSaleTemplate> templates,
IRepository<SalesDayEnd> dayEnds,
IRepository<Customer> customers,
IRepository<Item> items,
IRepository<Warehouse> warehouses,
IRepository<User> 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<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default)
{
IQueryable<BundleSaleTemplate> 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<BundleSaleTemplateSummaryDto>.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<BundleSaleTemplateSummaryDto>.Create(rows.Select(MapTemplateSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<BundleSaleTemplateDto?> 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<BundleSaleTemplateDto> 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);
}
/// <summary>
@@ -161,23 +124,13 @@ public sealed class BundleSaleService : IBundleSaleService
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
{
IQueryable<BundleSale> 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<BundleSaleSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<BundleSaleDto?> 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<BundleSaleDto> 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<BundleSaleDto> 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<BundleSaleDto> 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<BundleSaleTemplate> RequireTemplateAsync(int bundleSaleTemplateId, CancellationToken ct)
=> await _bundles.GetTemplateForReadAsync(bundleSaleTemplateId, ct)
?? throw new NotFoundException($"Bundle template {bundleSaleTemplateId} was not found.");
private async Task<List<BundleSaleLine>> BuildLinesAsync(
BundleSaleTemplate template, int warehouseId, IReadOnlyList<CreateBundleSaleTemplateLineRequest> 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);
+11 -75
View File
@@ -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;
/// <c>BankDeposit:CashTrackingStartDate</c>; 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
/// <see cref="DayEndReportService"/>: MariaDB can't portably group by a zone-shifted date).
/// Every query lives in <see cref="ICashInHandRepository"/>; this service keeps the balance rules.
/// </summary>
public sealed class CashInHandService : ICashInHandService
{
private const int MaxRangeDays = 92;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IRepository<ExpensePayment> _expensePayments;
private readonly IRepository<BankDeposit> _deposits;
private readonly ICashInHandRepository _cash;
private readonly ICurrentBranchContext _currentBranch;
private readonly BusinessDayClock _clock;
private readonly string? _defaultCashAccountCode;
public CashInHandService(
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoicePayment> invoicePayments,
IRepository<ExpensePayment> expensePayments, IRepository<BankDeposit> 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<CashPositionDto>(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<SalesSlip> 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<BundleSale> 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);
}
/// <summary>Cash received against Posted invoices — whatever day the invoice itself is from.</summary>
private IQueryable<SalesInvoicePayment> 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<ExpensePayment> 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);
}
/// <summary>Posted deposits by business date; <paramref name="lo"/> inclusive (null = no bound), <paramref name="hi"/> exclusive.</summary>
private IQueryable<BankDeposit> 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);
}
}
+75 -92
View File
@@ -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;
/// <summary>
/// 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 <see cref="ICategoryRepository"/>; writes run
/// inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class CategoryService : ICategoryService
{
private readonly IRepository<Category> _categories;
private readonly IRepository<SubCategory> _subCategories;
private readonly ICategoryRepository _categories;
private readonly IUnitOfWork _uow;
public CategoryService(
IRepository<Category> categories,
IRepository<SubCategory> 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<PagedResponse<CategoryDto>> 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<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
var (rows, total) = await _categories.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct);
return PagedResponse<CategoryDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<CategoryDto>?> 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<CategoryDto>(Map(category), category.RowVersion);
}
public async Task<ETagged<CategoryDto>> 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<CategoryDto>(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<CategoryDto>(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<PagedResponse<SubCategoryDto>> 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<SubCategoryDto>.Create(rows, query.Page, query.PageSize, total);
var (rows, total) = await _categories.ListSubCategoriesPagedAsync(categoryId, query.Q, status, query.Skip, query.PageSize, ct);
return PagedResponse<SubCategoryDto>.Create(rows.Select(MapSub).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<SubCategoryDto>?> 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<SubCategoryDto>(MapSub(sub), sub.RowVersion);
}
public async Task<ETagged<SubCategoryDto>> 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<SubCategoryDto>(MapSub(sub), sub.RowVersion);
}
@@ -176,44 +153,50 @@ public sealed class CategoryService : ICategoryService
public async Task<ETagged<SubCategoryDto>> 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<SubCategoryDto>(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)
/// <summary>Runs <paramref name="change"/> in a transaction; a lost optimistic-concurrency race becomes a 412.</summary>
private async Task InTransactionGuardingConcurrencyAsync(string label, Func<CancellationToken, Task> change, CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
await _uow.ExecuteInTransactionAsync(change, ct);
}
catch (DbUpdateConcurrencyException)
{
+45 -86
View File
@@ -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 <see cref="ICountRepository"/>; writes run inside
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class CountService : ICountService
{
private const string VarianceReasonCode = "VAR";
private readonly IRepository<StockCount> _counts;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _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<StockLayer> _stockLayers;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly ISerialTrackingService _serialTracking;
private readonly AdjustmentService _adjustments;
public CountService(
IRepository<StockCount> counts, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
IRepository<Serial> 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<PagedResponse<CountSummaryDto>> 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<CountSummaryDto>.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<CountSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
public async Task<CountDto?> 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<CountDto> 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<StockCountLine>();
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<CountDto> 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<CountPostResultDto> 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<int>());
}
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;
+100 -167
View File
@@ -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;
/// <summary>
/// Customer master service. Queries go through <see cref="ICustomerRepository"/>; writes run inside
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class CustomerService : ICustomerService
{
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly ICustomerRepository _customers;
private readonly IBranchConfigService _branchConfig;
private readonly IRepository<SalesConfig> _salesConfig;
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IUnitOfWork _uow;
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
private const string CashCustomerCode = "CUST-CASH";
public CustomerService(
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
IRepository<SalesConfig> salesConfig, IRepository<SalesInvoice> invoices,
IRepository<SalesInvoicePayment> 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<PagedResponse<CustomerDto>> 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<CustomerDto>.Create(await WithReceivablesAsync(rows, ct), query.Page, query.PageSize, total);
}
/// <summary>
/// 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.
/// </summary>
private async Task<List<CustomerDto>> WithReceivablesAsync(List<CustomerDto> 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<CustomerDto>.Create(items, query.Page, query.PageSize, total);
}
public async Task<ETagged<CustomerDto>?> 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<CustomerDto>(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<CustomerDto>(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();
/// <summary>Which optional customer fields Sales Configuration has switched on (all on when the row is missing).</summary>
private sealed record FieldSwitches(bool Phone, bool Email, bool Address, bool City, bool Country, bool CreditLimit, bool CreditDays);
private async Task<FieldSwitches> 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<CustomerFieldSwitches> GetFieldSwitchesAsync(CancellationToken ct) =>
await _customers.GetFieldSwitchesAsync(ct)
?? new CustomerFieldSwitches(true, true, true, true, true, true, true);
/// <summary>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).</summary>
+26 -102
View File
@@ -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;
/// <summary>
/// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Sales documents are stamped in
/// UTC, so each business day is the <see cref="BusinessDayClock"/> calendar day converted to a UTC
/// <c>[start, end)</c> 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 <see cref="SalesDayEndService"/> posts COGS from.
/// The cash figures (net cash, deposits, cash in hand) come from the Bank Deposit module's
/// <c>[start, end)</c> window. The database does the filtering and returns slim projections
/// (<see cref="IDayEndReportRepository"/>); 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 <see cref="SalesDayEndService"/>
/// posts COGS from. The cash figures (net cash, deposits, cash in hand) come from the Bank Deposit module's
/// <see cref="ICashInHandService"/> (docs/BANK_DEPOSIT_MODULE_PLAN.md §9); this report owns none of them.
/// </summary>
public sealed class DayEndReportService : IDayEndReportService
{
private const int MaxRangeDays = 92;
private const int IdChunk = 1000;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<Expense> _expenses;
private readonly IRepository<BankDeposit> _deposits;
private readonly IDayEndReportRepository _report;
private readonly ICashInHandService _cash;
private readonly ICurrentBranchContext _currentBranch;
private readonly BusinessDayClock _clock;
public DayEndReportService(
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoice> invoices,
IRepository<SalesInvoicePayment> invoicePayments, IRepository<StockLedger> ledger,
IRepository<Expense> expenses, IRepository<BankDeposit> 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<DayEndReportDayDto> Days, List<Sale> Sales);
private sealed record Loaded(List<DayEndReportDayDto> Days, List<Sale> Sales, IReadOnlyList<DayEndExpenseRow> Expenses);
private async Task<Loaded> 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<Sale>(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);
}
/// <summary>Sum of the stock ledger's Out value per document — the FIFO cost the sale consumed.</summary>
private async Task<Dictionary<int, decimal>> CostByDocAsync(string docType, IEnumerable<int> docIds, CancellationToken ct)
{
var result = new Dictionary<int, decimal>();
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<Expense> 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);
/// <summary>Flows are summed; cash in hand is a balance, so the range opens with its first day and closes with its last.</summary>
@@ -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
/// <see cref="PurchaseInvoicePaymentService"/>, so a rejected/unreachable GL post rolls the
/// whole payment back. Post &amp; Pay runs the accrual and a full or partial payment in one transaction.
/// Its own queries go through <see cref="IExpensePaymentRepository"/>.
/// </summary>
public sealed class ExpensePaymentService : IExpensePaymentService
{
private readonly IRepository<Expense> _expenses;
private readonly IRepository<ExpensePayment> _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<Expense> expenses, IRepository<ExpensePayment> 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<IReadOnlyList<ExpensePaymentDto>> 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();
}
+40 -83
View File
@@ -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
/// <see cref="PurchaseInvoiceService.PostAsync"/>, so a GL rejection rolls the post back.
/// Payments settle the accrual (<see cref="ExpensePaymentService"/>). Cancelling a Posted
/// expense with no payments posts the reversing entry.
/// expense with no payments posts the reversing entry. Queries go through <see cref="IExpenseRepository"/>;
/// every write runs inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class ExpenseService : IExpenseService
{
private readonly IRepository<Expense> _expenses;
private readonly IRepository<ExpenseTitle> _titles;
private readonly IRepository<Vendor> _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<Expense> expenses, IRepository<ExpenseTitle> titles, IRepository<Vendor> 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<PagedResponse<ExpenseSummaryDto>> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default)
{
IQueryable<Expense> 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<ExpenseSummaryDto>.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<ExpenseDto>(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<ExpenseDto> 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);
}
/// <summary>An expense with its category, title, vendor and payments. Shared with <see cref="ExpensePaymentService"/>.</summary>
internal Task<Expense?> 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<ExpenseTitle> 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<string?> 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) =>
+55 -57
View File
@@ -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 <see cref="IExpenseSetupRepository"/>; writes run inside
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class ExpenseSetupService : IExpenseSetupService
{
private readonly IRepository<ExpenseCategory> _categories;
private readonly IRepository<ExpenseTitle> _titles;
private readonly IExpenseSetupRepository _setup;
private readonly IUnitOfWork _uow;
public ExpenseSetupService(IRepository<ExpenseCategory> categories, IRepository<ExpenseTitle> titles, IUnitOfWork uow)
public ExpenseSetupService(IExpenseSetupRepository setup, IUnitOfWork uow)
{
_categories = categories;
_titles = titles;
_setup = setup;
_uow = uow;
}
public async Task<IReadOnlyList<ExpenseCategoryDto>> 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<ExpenseCategoryDto> 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<ExpenseTitleDto> 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<ExpenseTitleDto> 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);
}
+48 -157
View File
@@ -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 <see cref="IGrnRepository"/>; every write runs
/// inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
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<Grn> _grns;
private readonly IRepository<PurchaseOrder> _pos;
private readonly IRepository<PoLine> _poLines;
private readonly IRepository<Item> _items;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IRepository<SerialMovement> _serialMovements;
private readonly IGrnRepository _grns;
private readonly ISerialTrackingService _serialTracking;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<TaxRate> _taxRates;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -52,28 +40,12 @@ public sealed class GrnService : IGrnService
private readonly IProcurementConfigService _procurementConfig;
public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches, IRepository<Serial> serials, IRepository<SerialMovement> serialMovements, ISerialTrackingService serialTracking,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> 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<GrnSummaryDto>.Create(dtos, query.Page, query.PageSize, total);
}
/// <summary>
/// Server-side sort so paging stays correct: <c>created</c> / <c>vendor</c> / <c>total</c> / <c>status</c>, a leading
/// <c>-</c> for descending. Default (and any unknown key) is newest first; GrnId is the tiebreaker.
/// </summary>
private IQueryable<Grn> ApplySort(IQueryable<Grn> q, string? sort)
{
var key = (sort ?? "-created").Trim();
var desc = key.StartsWith('-');
key = key.TrimStart('-', '+').ToLowerInvariant();
IOrderedQueryable<Grn> 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<GrnDto?> 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<GrnConfirmResultDto> 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<ReleaseLineResultDto> 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<Batch?> 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<string> 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
/// </summary>
private async Task<List<StockLayer>> 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<PurchaseOrderStatus?> 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<GrnConfirmResultDto> 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,
+58 -40
View File
@@ -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 <c>CONFIG_DISABLED</c> while
/// <c>ProductConfig.ItemImagesEnabled</c> is off; listing, viewing and deleting stay open so
/// images added earlier remain manageable.
/// images added earlier remain manageable. Queries go through <see cref="IItemImageRepository"/>;
/// writes run inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class ItemImageService : IItemImageService
{
@@ -29,18 +29,14 @@ public sealed class ItemImageService : IItemImageService
[".webp"] = "image/webp"
};
private readonly IRepository<ItemImage> _images;
private readonly IRepository<Item> _items;
private readonly IItemImageRepository _images;
private readonly IProductConfigService _config;
private readonly IFileStorageService _storage;
private readonly IUnitOfWork _uow;
public ItemImageService(
IRepository<ItemImage> images, IRepository<Item> 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<List<ItemImageDto>> 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<ItemImageDto> 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.");
}
/// <summary>The item's current sort orders; throws when it already has the maximum number of images.</summary>
private async Task<IReadOnlyList<int>> 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);
}
+166 -303
View File
@@ -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.
/// </para>
/// Queries go through <see cref="IItemRepository"/>; every write runs inside
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class ItemService : IItemService
{
private readonly IRepository<Item> _items;
private readonly IRepository<Category> _categories;
private readonly IRepository<SubCategory> _subCategories;
private readonly IRepository<Brand> _brands;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<TaxRate> _taxRates;
private readonly IRepository<ApprovalConfig> _approvalConfig;
/// <summary>Price approvals on (the default) unless Settings → Approvals set price levels to 0.</summary>
private async Task<bool> 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<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IItemRepository _items;
private readonly IProductConfigService _config;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
public ItemService(
IRepository<Item> items,
IRepository<Category> categories,
IRepository<SubCategory> subCategories,
IRepository<Brand> brands,
IRepository<Uom> uoms,
IRepository<TaxRate> taxRates,
IRepository<Vendor> vendors,
IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers,
IRepository<StockLedger> stockLedger,
IRepository<GrnLine> grnLines,
IRepository<Batch> batches,
IRepository<Serial> serials,
IProductConfigService config,
INotificationService notifications,
IUnitOfWork uow,
IRepository<ApprovalConfig> 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;
}
/// <summary>Price approvals on (the default) unless Settings → Approvals set price levels to 0.</summary>
private async Task<bool> PricesNeedApprovalAsync(CancellationToken ct)
=> (await _items.GetPriceAdjustmentLevelsAsync(ct) ?? 2) > 0;
public async Task<PagedResponse<ItemListItemDto>> 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<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
return PagedResponse<ItemListItemDto>.Create(items, query.Page, query.PageSize, total);
}
public async Task<PagedResponse<ItemOverviewDto>> 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<IReadOnlyList<ItemPurchasePriceDto>> 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<ETagged<ItemDetailDto>?> 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<ItemDetailDto>(ToDetail(item), item.RowVersion);
}
public async Task<ETagged<ItemDetailDto>> 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<ETagged<ItemDetailDto>> 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<ItemDetailDto>(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<ItemReorderSettingsDto> 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);
}
/// <summary>
/// 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.
/// </summary>
private async Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct)
=> await _stockLayers.Query().AnyAsync(l => l.ItemId == itemId, ct)
|| await _stockLedger.Query().AnyAsync(l => l.ItemId == itemId, ct);
/// <summary>
/// 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);
}
}
/// <summary>Trims and blank-collapses a barcode, mirroring <c>Sku.Trim()</c> — a stray
/// space or an empty string would otherwise save as a value that never matches a real
/// scan, or collide with another item's blank barcode under the unique index.</summary>
@@ -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;
+41 -46
View File
@@ -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
/// <c>GET /item-types</c> 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 <see cref="IItemTypeRepository"/>;
/// writes run inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class ItemTypeService : IItemTypeService
{
private readonly IRepository<ItemType> _itemTypes;
private readonly IItemTypeRepository _itemTypes;
private readonly IUnitOfWork _uow;
public ItemTypeService(IRepository<ItemType> 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<PagedResponse<ItemTypeDto>> 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<ItemTypeDto>.Create(rows, query.Page, query.PageSize, total);
var (rows, total) = await _itemTypes.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct);
return PagedResponse<ItemTypeDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<ItemTypeDto>?> 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<ItemTypeDto>(Map(itemType), itemType.RowVersion);
}
public async Task<ETagged<ItemTypeDto>> 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<ItemTypeDto>(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);
@@ -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;
/// <summary>
/// Atomic document-number issuer. Uses a single <c>INSERT … ON DUPLICATE KEY UPDATE …
/// RETURNING</c> 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 <c>SqlQuery</c> 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
/// <see cref="INumberSequenceRepository.IncrementAsync"/>, 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.
/// <c>PO-BR-001-2026-00042</c>.
/// </summary>
/// <remarks>
/// MariaDB 10.5+ supports <c>RETURNING</c> 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
/// <c>LAST_INSERT_ID()</c> 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 <c>sequence_id</c> has gaps — harmless, do not "fix" it.
/// <c>ON DUPLICATE KEY UPDATE</c> 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
/// <c>IUnitOfWork.ExecuteInTransactionAsync</c>: it is called from inside other services' transactions
/// (bank deposits, GRNs, expenses, …), so it joins whatever transaction is current instead of starting one.
/// </remarks>
public sealed class NumberSequenceService : INumberSequenceService
{
private readonly ErpDbContext _db;
private readonly IRepository<Branch> _branches;
private readonly INumberSequenceRepository _sequences;
private readonly IBranchConfigService _branchConfig;
public NumberSequenceService(ErpDbContext db, IRepository<Branch> 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);
}
}
+14 -36
View File
@@ -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.
/// </para>
/// 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 <see cref="IOpeningStockRepository"/>; posting runs inside
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
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<OpeningBalanceRun> _runs;
private readonly IRepository<Item> _items;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<JournalEntryStub> _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<OpeningBalanceRun> runs, IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<StockLedger> ledger, IRepository<JournalEntryStub> 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<IReadOnlyList<OpeningStockRunSummaryDto>> 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<OpeningStockRunDto?> 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,
@@ -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;
/// <see cref="IApprovalService"/>). Approval then writes the new prices to the items and records an
/// <see cref="ItemPriceHistory"/> 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 <see cref="IPriceAdjustmentRepository"/>; every write runs inside
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class PriceAdjustmentService : IPriceAdjustmentService
{
public const string ApprovalDocType = "PADJ";
public const string ApprovalNavCode = "products.price-adjustments";
private readonly IRepository<PriceAdjustment> _adjustments;
private readonly IRepository<PriceAdjustmentLine> _lines;
private readonly IRepository<Item> _items;
private readonly IRepository<ItemPriceHistory> _history;
private readonly IRepository<User> _users;
private readonly IRepository<ApprovalConfig> _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<PriceAdjustment> adjustments, IRepository<PriceAdjustmentLine> lines, IRepository<Item> items,
IRepository<ItemPriceHistory> history, IRepository<User> users, IRepository<ApprovalConfig> 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<PagedResponse<PriceAdjustmentSummaryDto>> 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<PriceAdjustmentSummaryDto>.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<PriceAdjustmentSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
public async Task<PriceAdjustmentDto?> 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<PriceAdjustmentLine>();
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<IReadOnlyList<ItemPriceHistoryDto>> 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<int>().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<int>().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<bool> 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<PriceAdjustment> 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<PriceAdjustment> 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)
/// <summary>Applies <paramref name="decide"/> in a transaction; a lost race on the request becomes a 409.</summary>
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,
@@ -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;
/// <summary>Procurement configuration service over the singleton row seeded by DataSeeder.</summary>
/// <summary>
/// Procurement configuration service over the singleton row seeded by DataSeeder. Queries go through
/// <see cref="IProcurementConfigRepository"/>; the update runs inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/>
/// like <see cref="BankDepositService"/>.
/// </summary>
public sealed class ProcurementConfigService : IProcurementConfigService
{
private readonly IRepository<ProcurementConfig> _config;
private readonly IProcurementConfigRepository _config;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public ProcurementConfigService(IRepository<ProcurementConfig> 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<ETagged<ProcurementConfigDto>> 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<ProcurementConfigDto>(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<ProcurementConfigDto> 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);
}
@@ -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;
/// <summary>
/// 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
/// <see cref="IProductConfigRepository"/>; the update runs inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/>
/// like <see cref="BankDepositService"/>.
/// </summary>
public sealed class ProductConfigService : IProductConfigService
{
private readonly IRepository<ProductConfig> _config;
private readonly IProductConfigRepository _config;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public ProductConfigService(IRepository<ProductConfig> 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<ETagged<ProductConfigDto>> 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<ProductConfigDto>(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)
{
@@ -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 <see cref="SalesInvoicePaymentService"/> (that one
/// credits Accounts Receivable when the customer pays; this one debits Accounts Payable
/// when the vendor is paid) — using the same call-GL-before-commit pattern so a
/// rejected/unreachable GL post rolls back the whole payment atomically.
/// rejected/unreachable GL post rolls back the whole payment atomically (inside
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/>, like <see cref="BankDepositService"/>).
/// Queries go through <see cref="IPurchaseInvoicePaymentRepository"/>.
/// </summary>
public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentService
{
private readonly IRepository<PurchaseInvoice> _invoices;
private readonly IRepository<PurchaseInvoicePayment> _payments;
private readonly IPurchaseInvoicePaymentRepository _payments;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly IGlAccountConfigService _glAccounts;
public PurchaseInvoicePaymentService(
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> 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<PurchaseInvoicePaymentDto> 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<IReadOnlyList<PurchaseInvoicePaymentDto>> 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(
@@ -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 <see cref="SalesInvoiceService"/>'s AR recognition);
/// vendor payments are then recorded against this aggregate (see
/// <see cref="PurchaseInvoicePaymentService"/>) rather than per-GRN.
/// <see cref="PurchaseInvoicePaymentService"/>) rather than per-GRN. Queries go through
/// <see cref="IPurchaseInvoiceRepository"/>; every write runs inside
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
{
private readonly IRepository<PurchaseInvoice> _invoices;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Grn> _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<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> 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<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
{
IQueryable<PurchaseInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.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<PurchaseInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().AsNoTracking()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct);
var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: false, ct);
return invoice is null ? null : new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default)
{
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
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<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
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<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
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<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
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<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default)
{
return await _grns.Query().AsNoTracking()
.Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null)
.OrderBy(g => g.PostedAt)
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<List<Grn>> LoadAndValidateGrnsAsync(int vendorId, List<int> grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct)
private async Task<IReadOnlyList<Grn>> LoadAndValidateGrnsAsync(int vendorId, List<int> grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct)
{
var distinctIds = grnIds.Distinct().ToList();
var grns = await _grns.Query().Include(g => g.Lines)
.Where(g => distinctIds.Contains(g.GrnId)).ToListAsync(ct);
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);
+80 -144
View File
@@ -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 <see cref="IPurchaseOrderRepository"/>; every write runs
/// inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary>
public sealed class PurchaseOrderService : IPurchaseOrderService
{
private const string BaseCurrency = "LKR";
private readonly IRepository<PurchaseOrder> _pos;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Requisition> _requisitions;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _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<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> 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<PurchaseOrderSummaryDto>.Create(summaries, query.Page, query.PageSize, total);
}
/// <summary>
/// Server-side sort so paging stays correct: <c>created</c> / <c>vendor</c> / <c>total</c> / <c>status</c>, a leading
/// <c>-</c> for descending. Default (and any unknown key) is newest first. PoId is the tiebreaker, so a page
/// boundary never repeats or skips a row.
/// </summary>
private IQueryable<PurchaseOrder> ApplySort(IQueryable<PurchaseOrder> q, string? sort)
{
var key = (sort ?? "-created").Trim();
var desc = key.StartsWith('-');
key = key.TrimStart('-', '+').ToLowerInvariant();
IOrderedQueryable<PurchaseOrder> 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<ETagged<PurchaseOrderDto>?> 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<PurchaseOrderDto>(await ToDtoAsync(po, ct), po.RowVersion);
}
@@ -175,10 +112,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
public async Task<ETagged<PurchaseOrderDto>> 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).</summary>
private async Task<PurchaseOrder> LoadForApprovalDecisionAsync(int poId, CancellationToken ct)
{
var po = await _pos.Query()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
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<PurchaseOrderDto> 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<PurchaseOrderDto> 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<PurchaseOrder> 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<CreatePoLineInput> 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");
}
/// <summary>A PO is constrained to one branch (docs/BRANCH_ISOLATION_PLAN.md — Open
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.</summary>
private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable<int> warehouseIds, CancellationToken ct)
{
var ids = warehouseIds.Distinct().ToList();
var mismatched = await _warehouses.Query().AsNoTracking()
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId)
.Select(w => w.WarehouseId)
.ToListAsync(ct);
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<int> keySource, IEnumerable<int> requested, string label, CancellationToken ct)
private static void EnsureAllExist(IReadOnlyCollection<int> requested, IReadOnlyCollection<int> 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<PurchaseOrderDto> 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));
}
@@ -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 <see cref="Grn"/> posted — reuses
/// <c>Grn</c>'s own account config since it's reversing that posting.
/// <c>Grn</c>'s own account config since it's reversing that posting. Queries go through
/// <see cref="IPurchaseReturnRepository"/>; the post runs inside <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/>
/// like <see cref="BankDepositService"/>.
/// </summary>
public sealed class PurchaseReturnService : IPurchaseReturnService
{
private readonly IRepository<PurchaseReturn> _returns;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IPurchaseReturnRepository _returns;
private readonly IStockMutator _mutator;
private readonly IFifoCostingService _fifo;
private readonly IRepository<Serial> _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<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
IRepository<Serial> 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<PagedResponse<PurchaseReturnSummaryDto>> 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<PurchaseReturnSummaryDto>.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<PurchaseReturnSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
public async Task<PurchaseReturnDto?> 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<PurchaseReturnDto> 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<int, decimal>();
foreach (var line in rejectedLines)
{
var layers = await _layers.Query()
.Where(l => l.GrnLineId == line.GrnLineId && l.QtyRemaining > 0)
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.ToListAsync(token);
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)
@@ -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<string, string> = {
dashboard: "Dashboard",
products: "Items",
@@ -48,55 +50,81 @@ const SEGMENT_LABELS: Record<string, string> = {
"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<string, string> = {
"/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 (
<Breadcrumb className="mb-4 print:hidden">
<BreadcrumbList>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1
{uniqueCrumbs.map((crumb, index) => {
const isLast = index === uniqueCrumbs.length - 1
return (
<Fragment key={crumb.key}>
{index > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
) : (
<Link href={crumb.href} className={linkClass}>
<BreadcrumbPage>
{crumb.label}
</Link>
</BreadcrumbPage>
) : (
<span className="truncate text-muted-foreground">
{crumb.label}
</span>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}
</Fragment>
)
})}