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 public class CustomerFieldSwitchTests
{ {
private static CustomerService Svc(ErpDbContext db) => private static CustomerService Svc(ErpDbContext db) =>
new(new Repository<Customer>(db), new Repository<Warehouse>(db), new FakeBranchConfig(), new(new CustomerRepository(db), new FakeBranchConfig(), new PassThroughUnitOfWork(db));
new Repository<SalesConfig>(db), new Repository<SalesInvoice>(db), new Repository<SalesInvoicePayment>(db),
new PassThroughUnitOfWork(db));
private static async Task SwitchesAsync(ErpDbContext db, Action<SalesConfig> tweak) private static async Task SwitchesAsync(ErpDbContext db, Action<SalesConfig> tweak)
{ {
@@ -237,13 +237,8 @@ public class DayEndReportServiceTests
.Build(); .Build();
var clock = new BusinessDayClock(config); var clock = new BusinessDayClock(config);
var branch = new CurrentBranchContext(new TestCurrentUser()); var branch = new CurrentBranchContext(new TestCurrentUser());
var cash = new CashInHandService( var cash = new CashInHandService(new CashInHandRepository(db), branch, clock, config);
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db), return new DayEndReportService(new DayEndReportRepository(db), cash, branch, clock);
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);
} }
private static ErpDbContext CreateContext() private static ErpDbContext CreateContext()
+1 -4
View File
@@ -396,10 +396,7 @@ public class DualApprovalTests
(GlAccountRole.InventoryLoss, "5900")), (GlAccountRole.InventoryLoss, "5900")),
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db), new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
new Repository<ProductConfig>(db), user)); new Repository<ProductConfig>(db), user));
var prices = new PriceAdjustmentService( var prices = new PriceAdjustmentService(new PriceAdjustmentRepository(db), approvals, notifications, numbers, user, uow);
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);
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices }; 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) => 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) => private static VendorService VendorSvc(ErpDbContext db) =>
new(new Repository<Vendor>(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db)); new(new Repository<Vendor>(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db));
@@ -214,8 +214,7 @@ public class ProcurementAndOpeningBalanceTests
// ---------------------------------------------------------------- purchase-order tax // ---------------------------------------------------------------- purchase-order tax
private static PurchaseOrderService PoSvc(ErpDbContext db) => private static PurchaseOrderService PoSvc(ErpDbContext db) =>
new(new Repository<PurchaseOrder>(db), new Repository<Vendor>(db), new Repository<Requisition>(db), new(new PurchaseOrderRepository(db),
new Repository<Item>(db), new Repository<Uom>(db), new Repository<Warehouse>(db),
new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(), new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(),
ConfigService(db), new PassThroughUnitOfWork(db)); ConfigService(db), new PassThroughUnitOfWork(db));
@@ -561,9 +560,7 @@ public class ProcurementAndOpeningBalanceTests
var fifo = new FifoCostingService( var fifo = new FifoCostingService(
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts); new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
return new OpeningStockService( return new OpeningStockService(
new Repository<OpeningBalanceRun>(db), new Repository<Item>(db), new Repository<Warehouse>(db), new OpeningStockRepository(db), fifo, gl, glAccounts, new TestCurrentUser(), new PassThroughUnitOfWork(db));
new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), fifo, gl, glAccounts,
new TestCurrentUser(), new PassThroughUnitOfWork(db));
} }
private static Item StockedItem(int id, Action<Item>? tweak = null) 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) public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
=> new(new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db), => new(new CashInHandRepository(db), new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db),
new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl) 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 // Unit of work + generic repository base
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>(); builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); 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) // Master-data services (docs/11 §2)
builder.Services.AddScoped<ICustomerService, CustomerService>(); 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);
}
}
+28 -31
View File
@@ -7,7 +7,7 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IBrandRepository
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -17,10 +17,10 @@ namespace ERPCore.Services;
/// </summary> /// </summary>
public sealed class BrandService : IBrandService public sealed class BrandService : IBrandService
{ {
private readonly IRepository<Brand> _brands; private readonly IBrandRepository _brands;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public BrandService(IRepository<Brand> brands, IUnitOfWork uow) public BrandService(IBrandRepository brands, IUnitOfWork uow)
{ {
_brands = brands; _brands = brands;
_uow = uow; _uow = uow;
@@ -28,45 +28,36 @@ public sealed class BrandService : IBrandService
public async Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) public async Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{ {
var q = _brands.Query().AsNoTracking(); var (rows, total) = await _brands.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct);
if (!string.IsNullOrWhiteSpace(query.Q)) return PagedResponse<BrandDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
{
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);
} }
public async Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default) public async Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default)
{ {
var brand = await _brands.Query().AsNoTracking() var brand = await _brands.GetForReadAsync(brandId, ct);
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
return brand is null ? null : new ETagged<BrandDto>(Map(brand), brand.RowVersion); return brand is null ? null : new ETagged<BrandDto>(Map(brand), brand.RowVersion);
} }
public async Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default) public async Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default)
{ {
var name = request.Name.Trim(); var name = request.Name.Trim();
if (await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower(), ct))
// 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 =>
{
if (await _brands.NameExistsAsync(name, ct: token))
throw new ConflictException($"A brand named '{name}' already exists."); throw new ConflictException($"A brand named '{name}' already exists.");
var brand = new Brand var entity = new Brand
{ {
Name = name, Name = name,
Status = EntityStatus.Active, Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
await _brands.AddAsync(entity, token);
await _brands.AddAsync(brand, ct); return entity;
await _uow.SaveChangesAsync(ct); }, ct);
return new ETagged<BrandDto>(Map(brand), brand.RowVersion); 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); throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412);
var name = request.Name.Trim(); var name = request.Name.Trim();
try
{
await _uow.ExecuteInTransactionAsync(async token =>
{
if (!string.Equals(brand.Name, name, StringComparison.Ordinal) if (!string.Equals(brand.Name, name, StringComparison.Ordinal)
&& await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower() && b.BrandId != brandId, ct)) && await _brands.NameExistsAsync(name, brandId, token))
throw new ConflictException($"A brand named '{name}' already exists."); throw new ConflictException($"A brand named '{name}' already exists.");
brand.Name = name; brand.Name = name;
brand.UpdatedAt = DateTime.UtcNow; brand.UpdatedAt = DateTime.UtcNow;
return 0;
try }, ct);
{
await _uow.SaveChangesAsync(ct);
} }
catch (DbUpdateConcurrencyException) catch (DbUpdateConcurrencyException)
{ {
@@ -105,9 +99,12 @@ public sealed class BrandService : IBrandService
var brand = await _brands.GetByIdAsync(brandId, ct) var brand = await _brands.GetByIdAsync(brandId, ct)
?? throw new NotFoundException($"Brand {brandId} was not found."); ?? throw new NotFoundException($"Brand {brandId} was not found.");
await _uow.ExecuteInTransactionAsync(token =>
{
brand.Status = status; brand.Status = status;
brand.UpdatedAt = DateTime.UtcNow; brand.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
} }
private static BrandDto Map(Brand b) => new(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt); private static BrandDto Map(Brand b) => new(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt);
+79 -104
View File
@@ -3,101 +3,55 @@ using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales; using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock; using ERPCore.Services.Stock;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; 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 public sealed class BundleSaleService : IBundleSaleService
{ {
private readonly IRepository<BundleSaleTemplate> _templates; private readonly IBundleSaleRepository _bundles;
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 ISalesDomainService _sales; private readonly ISalesDomainService _sales;
private readonly ISalesPostingService _posting; private readonly ISalesPostingService _posting;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public BundleSaleService( public BundleSaleService(
IRepository<BundleSale> bundles, IBundleSaleRepository bundles,
IRepository<BundleSaleTemplate> templates,
IRepository<SalesDayEnd> dayEnds,
IRepository<Customer> customers,
IRepository<Item> items,
IRepository<Warehouse> warehouses,
IRepository<User> users,
ISalesDomainService sales, ISalesDomainService sales,
ISalesPostingService posting, ISalesPostingService posting,
ICurrentUser currentUser,
INumberSequenceService numbers, INumberSequenceService numbers,
IUnitOfWork uow) IUnitOfWork uow)
{ {
_templates = templates;
_bundles = bundles; _bundles = bundles;
_dayEnds = dayEnds;
_customers = customers;
_items = items;
_warehouses = warehouses;
_users = users;
_sales = sales; _sales = sales;
_posting = posting; _posting = posting;
_currentUser = currentUser;
_numbers = numbers; _numbers = numbers;
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default) public async Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default)
{ {
IQueryable<BundleSaleTemplate> q = _templates.Query().AsNoTracking().Include(x => x.Lines); var (rows, total) = await _bundles.ListTemplatesPagedAsync(query.Q, query.Skip, query.PageSize, ct);
if (!string.IsNullOrWhiteSpace(query.Q)) return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(MapTemplateSummary).ToList(), query.Page, query.PageSize, total);
{
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);
} }
public async Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default) 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); var template = await _bundles.GetTemplateForReadAsync(bundleSaleTemplateId, ct);
return template is null ? null : new BundleSaleTemplateDto( return template is null ? null : MapTemplate(template);
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());
} }
public async Task<BundleSaleTemplateDto> CreateTemplateAsync(CreateBundleSaleTemplateRequest request, CancellationToken ct = default) 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.BundlePrice is { } bundlePrice)
{ {
if (!request.Lines.Any(l => l.IncludeInBundle)) if (!request.Lines.Any(l => l.IncludeInBundle))
@@ -105,14 +59,26 @@ public sealed class BundleSaleService : IBundleSaleService
AllocateBundlePrice(request.Lines, bundlePrice); AllocateBundlePrice(request.Lines, bundlePrice);
} }
var template = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new BundleSaleTemplate
{
TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, token),
TemplateName = request.TemplateName,
Description = request.Description,
BundlePrice = request.BundlePrice ?? 0m,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
foreach (var line in request.Lines) foreach (var line in request.Lines)
{ {
if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct)) if (!await _bundles.ItemExistsAsync(line.ItemId, token))
throw new NotFoundException($"Item {line.ItemId} was not found."); throw new NotFoundException($"Item {line.ItemId} was not found.");
if (!await _warehouses.Query().AsNoTracking().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct)) if (!await _bundles.WarehouseExistsAsync(line.WarehouseId, token))
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found."); throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
template.Lines.Add(new BundleSaleTemplateLine entity.Lines.Add(new BundleSaleTemplateLine
{ {
ItemId = line.ItemId, ItemId = line.ItemId,
WarehouseId = line.WarehouseId, WarehouseId = line.WarehouseId,
@@ -123,14 +89,11 @@ public sealed class BundleSaleService : IBundleSaleService
}); });
} }
await _templates.AddAsync(template, ct); await _bundles.AddTemplateAsync(entity, token);
await _uow.SaveChangesAsync(ct); return entity;
}, ct);
return new BundleSaleTemplateDto( return MapTemplate(template);
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());
} }
/// <summary> /// <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) 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); var (rows, total) = await _bundles.ListPagedAsync(query.Q, status, customerId, warehouseId, query.Skip, query.PageSize, ct);
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);
return PagedResponse<BundleSaleSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); return PagedResponse<BundleSaleSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
} }
public async Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default) 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); return bundle is null ? null : Map(bundle);
} }
@@ -188,19 +141,18 @@ public sealed class BundleSaleService : IBundleSaleService
{ {
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
var bundle = await _uow.ExecuteInTransactionAsync(async token =>
{
// Same guard as SalesSlipService.CreateAsync — a bundle sale is a cashier document // 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). // 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 today = DateOnly.FromDateTime(DateTime.UtcNow);
var alreadyClosed = await _dayEnds.Query().AsNoTracking() if (await _bundles.IsDayClosedAsync(request.CashierUserId, today, token))
.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."); 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) var template = await RequireTemplateAsync(request.BundleSaleTemplateId, token);
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct); var entity = new BundleSale
var bundle = new BundleSale
{ {
BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, ct), BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, token),
BundleDate = DateTime.UtcNow, BundleDate = DateTime.UtcNow,
CustomerId = request.CustomerId, CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId, WarehouseId = request.WarehouseId,
@@ -211,59 +163,74 @@ public sealed class BundleSaleService : IBundleSaleService
Status = BundleSaleStatus.Draft, Status = BundleSaleStatus.Draft,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); entity.CustomerSnapshotName = await _bundles.GetCustomerSnapshotNameAsync(request.CustomerId, token);
bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct); entity.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, token);
Recalculate(bundle, request.BundlePrice, request.CardAmount); Recalculate(entity, request.BundlePrice, request.CardAmount);
bundle.BundleCode = $"{bundle.BundleNo}-B"; entity.BundleCode = $"{entity.BundleNo}-B";
await _bundles.AddAsync(bundle, ct); await _bundles.AddAsync(entity, token);
await _uow.SaveChangesAsync(ct); return entity;
}, ct);
return Map(bundle); return Map(bundle);
} }
public async Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default) 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."); ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
if (bundle.Status != BundleSaleStatus.Draft) if (bundle.Status != BundleSaleStatus.Draft)
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited."); throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited.");
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); 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); await _uow.ExecuteInTransactionAsync(async token =>
{
var template = await RequireTemplateAsync(request.BundleSaleTemplateId, token);
bundle.CustomerId = request.CustomerId; bundle.CustomerId = request.CustomerId;
bundle.WarehouseId = request.WarehouseId; bundle.WarehouseId = request.WarehouseId;
bundle.CashierUserId = request.CashierUserId; bundle.CashierUserId = request.CashierUserId;
bundle.BundleSaleTemplateId = request.BundleSaleTemplateId; bundle.BundleSaleTemplateId = request.BundleSaleTemplateId;
bundle.BundleName = request.BundleName; bundle.BundleName = request.BundleName;
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); bundle.CustomerSnapshotName = await _bundles.GetCustomerSnapshotNameAsync(request.CustomerId, token);
bundle.Lines.Clear(); bundle.Lines.Clear();
foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line); foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, token)) bundle.Lines.Add(line);
Recalculate(bundle, request.BundlePrice, request.CardAmount); Recalculate(bundle, request.BundlePrice, request.CardAmount);
bundle.UpdatedAt = DateTime.UtcNow; bundle.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return 0;
}, ct);
return Map(bundle); return Map(bundle);
} }
public async Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default) public async Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default)
{ {
await _posting.PostBundleAsync(bundleSaleId, ct); await _posting.PostBundleAsync(bundleSaleId, ct);
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines) var bundle = await _bundles.GetForReadAsync(bundleSaleId, ct)
.FirstAsync(x => x.BundleSaleId == bundleSaleId, ct); ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
return Map(bundle); return Map(bundle);
} }
public async Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default) 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."); ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
if (bundle.Status != BundleSaleStatus.Draft) if (bundle.Status != BundleSaleStatus.Draft)
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled."); throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled.");
await _uow.ExecuteInTransactionAsync(token =>
{
bundle.Status = BundleSaleStatus.Cancelled; bundle.Status = BundleSaleStatus.Cancelled;
bundle.UpdatedAt = DateTime.UtcNow; bundle.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
return Map(bundle); 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( private async Task<List<BundleSaleLine>> BuildLinesAsync(
BundleSaleTemplate template, int warehouseId, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct) 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. // Keep any per-line warehouse input from drifting away from the header.
var lineWarehouseId = warehouseId; 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); 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); var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
lines.Add(new BundleSaleLine lines.Add(new BundleSaleLine
{ {
ItemId = r.ItemId, ItemId = r.ItemId,
Description = item.Name, Description = itemName,
Qty = r.Qty, Qty = r.Qty,
WarehouseId = lineWarehouseId, WarehouseId = lineWarehouseId,
UnitPrice = r.UnitPrice, UnitPrice = r.UnitPrice,
@@ -326,6 +293,14 @@ public sealed class BundleSaleService : IBundleSaleService
bundle.CashAmount = bundle.GrandTotal - card; 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( 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); 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 System.Globalization;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts; using ERPCore.Dtos.Accounts;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -17,30 +14,21 @@ namespace ERPCore.Services;
/// <c>BankDeposit:CashTrackingStartDate</c>; the opening balance before a window is a handful of /// <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 /// 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). /// <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> /// </summary>
public sealed class CashInHandService : ICashInHandService public sealed class CashInHandService : ICashInHandService
{ {
private const int MaxRangeDays = 92; private const int MaxRangeDays = 92;
private readonly IRepository<SalesSlip> _slips; private readonly ICashInHandRepository _cash;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IRepository<ExpensePayment> _expensePayments;
private readonly IRepository<BankDeposit> _deposits;
private readonly ICurrentBranchContext _currentBranch; private readonly ICurrentBranchContext _currentBranch;
private readonly BusinessDayClock _clock; private readonly BusinessDayClock _clock;
private readonly string? _defaultCashAccountCode; private readonly string? _defaultCashAccountCode;
public CashInHandService( public CashInHandService(
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoicePayment> invoicePayments, ICashInHandRepository cash, ICurrentBranchContext currentBranch, BusinessDayClock clock, IConfiguration configuration)
IRepository<ExpensePayment> expensePayments, IRepository<BankDeposit> deposits,
ICurrentBranchContext currentBranch, BusinessDayClock clock, IConfiguration configuration)
{ {
_slips = slips; _cash = cash;
_bundles = bundles;
_invoicePayments = invoicePayments;
_expensePayments = expensePayments;
_deposits = deposits;
_currentBranch = currentBranch; _currentBranch = currentBranch;
_clock = clock; _clock = clock;
_defaultCashAccountCode = configuration["BankDeposit:DefaultCashAccountCode"]; _defaultCashAccountCode = configuration["BankDeposit:DefaultCashAccountCode"];
@@ -105,29 +93,21 @@ public sealed class CashInHandService : ICashInHandService
{ {
DateTime? lo = TrackingStartDate is { } s ? _clock.ToUtc(s) : null; DateTime? lo = TrackingStartDate is { } s ? _clock.ToUtc(s) : null;
var hi = _clock.ToUtc(from); var hi = _clock.ToUtc(from);
var cashIn = await Slips(lo, hi, branch).SumAsync(s => s.CashAmount, ct) var cashIn = await _cash.SumCashInAsync(lo, hi, branch, ct);
+ await Bundles(lo, hi, branch).SumAsync(b => b.CashAmount, ct) var cashOut = await _cash.SumCashOutAsync(lo, hi, branch, ct);
+ await InvoicePayments(lo, hi, branch).SumAsync(p => p.Amount, ct); var deposited = await _cash.SumDepositsAsync(TrackingStartDate, from, branch, 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);
opening = cashIn - cashOut - deposited; opening = cashIn - cashOut - deposited;
} }
// The window itself, bucketed by local day. // The window itself, bucketed by local day.
var start = _clock.ToUtc(from); var start = _clock.ToUtc(from);
var end = _clock.ToUtc(to.AddDays(1)); var end = _clock.ToUtc(to.AddDays(1));
var inRows = new List<(DateTime At, decimal Amount)>(); var inRows = await _cash.ListCashInAsync(start, end, branch, ct);
inRows.AddRange((await Slips(start, end, branch).Select(s => new { s.SlipDate, s.CashAmount }).ToListAsync(ct)) var outRows = await _cash.ListCashOutAsync(start, end, branch, ct);
.Select(r => (r.SlipDate, r.CashAmount))); var depositRows = await _cash.ListDepositsAsync(from, to.AddDays(1), branch, ct);
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 inByDay = inRows.GroupBy(r => _clock.LocalDay(r.At)).ToDictionary(g => g.Key, g => g.Sum(r => r.Amount)); 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 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); var days = new List<CashPositionDto>(to.DayNumber - from.DayNumber + 1);
@@ -149,48 +129,4 @@ public sealed class CashInHandService : ICashInHandService
} }
return days; 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);
}
} }
+52 -69
View File
@@ -7,28 +7,24 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in ICategoryRepository
namespace ERPCore.Services; namespace ERPCore.Services;
/// <summary> /// <summary>
/// Category + subcategory master service (FR-MD-04). The hierarchy is exactly two levels: /// 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 /// 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> /// </summary>
public sealed class CategoryService : ICategoryService public sealed class CategoryService : ICategoryService
{ {
private readonly IRepository<Category> _categories; private readonly ICategoryRepository _categories;
private readonly IRepository<SubCategory> _subCategories;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public CategoryService( public CategoryService(ICategoryRepository categories, IUnitOfWork uow)
IRepository<Category> categories,
IRepository<SubCategory> subCategories,
IUnitOfWork uow)
{ {
_categories = categories; _categories = categories;
_subCategories = subCategories;
_uow = uow; _uow = uow;
} }
@@ -37,45 +33,34 @@ public sealed class CategoryService : ICategoryService
public async Task<PagedResponse<CategoryDto>> ListAsync( public async Task<PagedResponse<CategoryDto>> ListAsync(
PageQuery query, EntityStatus? status, CancellationToken ct = default) PageQuery query, EntityStatus? status, CancellationToken ct = default)
{ {
var q = _categories.Query().AsNoTracking(); var (rows, total) = await _categories.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct);
if (!string.IsNullOrWhiteSpace(query.Q)) return PagedResponse<CategoryDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
{
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);
} }
public async Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default) public async Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default)
{ {
var category = await _categories.Query().AsNoTracking() var category = await _categories.GetForReadAsync(categoryId, ct);
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
return category is null ? null : new ETagged<CategoryDto>(Map(category), category.RowVersion); return category is null ? null : new ETagged<CategoryDto>(Map(category), category.RowVersion);
} }
public async Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default) public async Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
{ {
var name = request.Name.Trim(); var name = request.Name.Trim();
if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct))
var category = await _uow.ExecuteInTransactionAsync(async token =>
{
if (await _categories.NameExistsAsync(name, ct: token))
throw new ConflictException($"A category named '{name}' already exists."); throw new ConflictException($"A category named '{name}' already exists.");
var category = new Category var entity = new Category
{ {
Name = name, Name = name,
Status = EntityStatus.Active, Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
await _categories.AddAsync(entity, token);
await _categories.AddAsync(category, ct); return entity;
await _uow.SaveChangesAsync(ct); }, ct);
return new ETagged<CategoryDto>(Map(category), category.RowVersion); 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); throw new DomainException(ErrorCodes.ConcurrencyConflict, "The category was modified by another request.", 412);
var name = request.Name.Trim(); var name = request.Name.Trim();
await InTransactionGuardingConcurrencyAsync("category", async token =>
{
if (!string.Equals(category.Name, name, StringComparison.Ordinal) if (!string.Equals(category.Name, name, StringComparison.Ordinal)
&& await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower() && c.CategoryId != categoryId, ct)) && await _categories.NameExistsAsync(name, categoryId, token))
throw new ConflictException($"A category named '{name}' already exists."); throw new ConflictException($"A category named '{name}' already exists.");
category.Name = name; category.Name = name;
category.UpdatedAt = DateTime.UtcNow; category.UpdatedAt = DateTime.UtcNow;
}, ct);
await SaveGuardingConcurrencyAsync("category", ct);
return new ETagged<CategoryDto>(Map(category), category.RowVersion); return new ETagged<CategoryDto>(Map(category), category.RowVersion);
} }
@@ -106,9 +94,12 @@ public sealed class CategoryService : ICategoryService
var category = await _categories.GetByIdAsync(categoryId, ct) var category = await _categories.GetByIdAsync(categoryId, ct)
?? throw new NotFoundException($"Category {categoryId} was not found."); ?? throw new NotFoundException($"Category {categoryId} was not found.");
await _uow.ExecuteInTransactionAsync(_ =>
{
category.Status = status; category.Status = status;
category.UpdatedAt = DateTime.UtcNow; category.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
} }
// Subcategories ------------------------------------------------------------ // Subcategories ------------------------------------------------------------
@@ -116,59 +107,45 @@ public sealed class CategoryService : ICategoryService
public async Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync( public async Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default) 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."); throw new NotFoundException($"Category {categoryId} was not found.");
var q = _subCategories.Query().AsNoTracking().Where(s => s.CategoryId == categoryId); var (rows, total) = await _categories.ListSubCategoriesPagedAsync(categoryId, query.Q, status, query.Skip, query.PageSize, ct);
if (!string.IsNullOrWhiteSpace(query.Q)) return PagedResponse<SubCategoryDto>.Create(rows.Select(MapSub).ToList(), query.Page, query.PageSize, total);
{
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);
} }
public async Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default) public async Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default)
{ {
var sub = await _subCategories.Query().AsNoTracking() var sub = await _categories.GetSubCategoryForReadAsync(subCategoryId, ct);
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
return sub is null ? null : new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion); return sub is null ? null : new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
} }
public async Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync( public async Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(
int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default) int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default)
{ {
var category = await _categories.Query().AsNoTracking() var category = await _categories.GetForReadAsync(categoryId, ct)
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct)
?? throw new NotFoundException($"Category {categoryId} was not found."); ?? throw new NotFoundException($"Category {categoryId} was not found.");
if (category.Status != EntityStatus.Active) if (category.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} is inactive.", 422); throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} is inactive.", 422);
var name = request.Name.Trim(); var name = request.Name.Trim();
if (await _subCategories.Query().AnyAsync(
s => s.CategoryId == categoryId && s.Name.ToLower() == name.ToLower(), ct)) var sub = await _uow.ExecuteInTransactionAsync(async token =>
{
if (await _categories.SubCategoryNameExistsAsync(categoryId, name, ct: token))
throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}."); throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}.");
var sub = new SubCategory var entity = new SubCategory
{ {
CategoryId = categoryId, CategoryId = categoryId,
Name = name, Name = name,
Status = EntityStatus.Active, Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
await _categories.AddSubCategoryAsync(entity, token);
await _subCategories.AddAsync(sub, ct); return entity;
await _uow.SaveChangesAsync(ct); }, ct);
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion); return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
} }
@@ -176,44 +153,50 @@ public sealed class CategoryService : ICategoryService
public async Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync( public async Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(
int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default) 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."); ?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
if (sub.RowVersion != expectedRowVersion) if (sub.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The subcategory was modified by another request.", 412); throw new DomainException(ErrorCodes.ConcurrencyConflict, "The subcategory was modified by another request.", 412);
var name = request.Name.Trim(); var name = request.Name.Trim();
await InTransactionGuardingConcurrencyAsync("subcategory", async token =>
{
if (!string.Equals(sub.Name, name, StringComparison.Ordinal) if (!string.Equals(sub.Name, name, StringComparison.Ordinal)
&& await _subCategories.Query().AnyAsync( && await _categories.SubCategoryNameExistsAsync(sub.CategoryId, name, subCategoryId, token))
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}."); 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 // Name only — reparenting is not offered, since it would silently invalidate the
// category of every item pointing at this subcategory. // category of every item pointing at this subcategory.
sub.Name = name; sub.Name = name;
sub.UpdatedAt = DateTime.UtcNow; sub.UpdatedAt = DateTime.UtcNow;
}, ct);
await SaveGuardingConcurrencyAsync("subcategory", ct);
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion); return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
} }
public async Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default) 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."); ?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
await _uow.ExecuteInTransactionAsync(_ =>
{
sub.Status = status; sub.Status = status;
sub.UpdatedAt = DateTime.UtcNow; sub.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); 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 try
{ {
await _uow.SaveChangesAsync(ct); await _uow.ExecuteInTransactionAsync(change, ct);
} }
catch (DbUpdateConcurrencyException) catch (DbUpdateConcurrencyException)
{ {
+35 -76
View File
@@ -10,7 +10,6 @@ using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using ERPCore.System.Time; using ERPCore.System.Time;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -21,94 +20,62 @@ namespace ERPCore.Services;
/// C.7). That adjustment follows the same approval rules as any other /// 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 /// (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. /// 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> /// </summary>
public sealed class CountService : ICountService public sealed class CountService : ICountService
{ {
private const string VarianceReasonCode = "VAR"; private const string VarianceReasonCode = "VAR";
private readonly IRepository<StockCount> _counts; private readonly ICountRepository _counts;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IFifoCostingService _fifo; private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow; 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 ISerialTrackingService _serialTracking;
private readonly AdjustmentService _adjustments; private readonly AdjustmentService _adjustments;
public CountService( public CountService(
IRepository<StockCount> counts, IRepository<Warehouse> warehouses, ICountRepository counts, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IBranchConfigService branchConfig, IUnitOfWork uow, ISerialTrackingService serialTracking, AdjustmentService adjustments)
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
IRepository<Serial> serials, ISerialTrackingService serialTracking, AdjustmentService adjustments)
{ {
_serialTracking = serialTracking;
_counts = counts; _counts = counts;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_fifo = fifo; _fifo = fifo;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
_branchConfig = branchConfig; _branchConfig = branchConfig;
_uow = uow; _uow = uow;
_stockLayers = stockLayers; _serialTracking = serialTracking;
_batches = batches;
_serials = serials;
_adjustments = adjustments; _adjustments = adjustments;
} }
public async Task<PagedResponse<CountSummaryDto>> ListAsync( public async Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, int? branchId, CancellationToken ct = default) PageQuery query, CountStatus? status, int? warehouseId, int? branchId, CancellationToken ct = default)
{ {
var q = _counts.Query().AsNoTracking(); var (rows, total) = await _counts.ListPagedAsync(query.Q, status, warehouseId, branchId, query.Skip, query.PageSize, ct);
var items = rows.Select(r => new CountSummaryDto(
if (!string.IsNullOrWhiteSpace(query.Q)) 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();
var term = query.Q.Trim(); return PagedResponse<CountSummaryDto>.Create(items, query.Page, query.PageSize, total);
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);
} }
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default) public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
{ {
var count = await _counts.Query().AsNoTracking() var count = await _counts.GetForReadAsync(countId, ct);
.Include(c => c.Lines).ThenInclude(l => l.Batch)
.Include(c => c.Lines).ThenInclude(l => l.Serial)
.FirstOrDefaultAsync(c => c.CountId == countId, ct);
return count is null ? null : Map(count); return count is null ? null : Map(count);
} }
public async Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default) 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); throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var itemIds = request.ItemIds.Distinct().ToList(); var itemIds = request.ItemIds.Distinct().ToList();
foreach (var id in itemIds) 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); throw new DomainException(ErrorCodes.Validation, $"Item {id} does not exist.", 422);
// Snapshot system quantities now (immutable once opened). Batch/serial-tracked items // 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>(); var lines = new List<StockCountLine>();
foreach (var id in itemIds) 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) 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) if (batches.Count == 0)
{ {
lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = 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) foreach (var batch in batches)
{ {
var systemQty = await _stockLayers.Query().AsNoTracking() var systemQty = await _counts.SumBatchQtyAsync(id, request.WarehouseId, batch.BatchId, ct);
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == batch.BatchId)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
lines.Add(new StockCountLine { ItemId = id, BatchId = batch.BatchId, SystemQty = systemQty }); 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. // Stock that reached the item without a batch (e.g. an adjustment) is counted on its own line.
var unbatched = await _stockLayers.Query().AsNoTracking() var unbatched = await _counts.SumBatchQtyAsync(id, request.WarehouseId, null, ct);
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == null)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (unbatched != 0) if (unbatched != 0)
lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = unbatched }); 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 // 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). // one aggregate line for any pre-tracking serial-less stock still here (D6).
var serials = await _serials.Query().AsNoTracking() var serials = await _counts.ListCountableSerialsAsync(id, request.WarehouseId, ct);
.Where(s => s.ItemId == id && s.WarehouseId == request.WarehouseId
&& (s.Status == SerialStatus.InStock || s.Status == SerialStatus.Damaged))
.OrderBy(s => s.SerialNo).ToListAsync(ct);
foreach (var serial in serials) foreach (var serial in serials)
{ {
var open = await _stockLayers.Query().AsNoTracking().AnyAsync( var open = await _counts.HasOpenSerialLayerAsync(id, request.WarehouseId, serial.SerialId, ct);
l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == serial.SerialId && l.QtyRemaining > 0, ct);
lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 }); lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 });
} }
var untracked = await _stockLayers.Query().AsNoTracking() var untracked = await _counts.SumUnserializedQtyAsync(id, request.WarehouseId, ct);
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == null)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (untracked != 0 || serials.Count == 0) if (untracked != 0 || serials.Count == 0)
lines.Add(new StockCountLine { ItemId = id, SystemQty = untracked }); lines.Add(new StockCountLine { ItemId = id, SystemQty = untracked });
} }
@@ -189,14 +146,13 @@ public sealed class CountService : ICountService
public async Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default) public async Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default)
{ {
var count = await _counts.Query() var count = await _counts.GetForUpdateAsync(countId, ct)
.Include(c => c.Lines).ThenInclude(l => l.Batch)
.Include(c => c.Lines).ThenInclude(l => l.Serial)
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
?? throw new NotFoundException($"Count {countId} was not found."); ?? throw new NotFoundException($"Count {countId} was not found.");
if (count.Status == CountStatus.Posted) if (count.Status == CountStatus.Posted)
throw new ConflictException($"Count {countId} is already posted."); throw new ConflictException($"Count {countId} is already posted.");
await _uow.ExecuteInTransactionAsync(_ =>
{
foreach (var input in request.Lines) foreach (var input in request.Lines)
{ {
var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId) var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId)
@@ -208,16 +164,15 @@ public sealed class CountService : ICountService
} }
count.Status = CountStatus.Counted; count.Status = CountStatus.Counted;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
return Map(count); return Map(count);
} }
public async Task<CountPostResultDto> PostAsync(int countId, CancellationToken ct = default) public async Task<CountPostResultDto> PostAsync(int countId, CancellationToken ct = default)
{ {
var count = await _counts.Query() var count = await _counts.GetForUpdateAsync(countId, ct)
.Include(c => c.Lines).ThenInclude(l => l.Batch)
.Include(c => c.Lines).ThenInclude(l => l.Serial)
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
?? throw new NotFoundException($"Count {countId} was not found."); ?? throw new NotFoundException($"Count {countId} was not found.");
if (count.Status == CountStatus.Posted) if (count.Status == CountStatus.Posted)
throw new ConflictException($"Count {countId} is already posted."); throw new ConflictException($"Count {countId} is already posted.");
@@ -229,14 +184,16 @@ public sealed class CountService : ICountService
// No variances → just close the count, no adjustment. // No variances → just close the count, no adjustment.
if (varianceLines.Count == 0) if (varianceLines.Count == 0)
{
await _uow.ExecuteInTransactionAsync(_ =>
{ {
count.Status = CountStatus.Posted; count.Status = CountStatus.Posted;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
return new CountPostResultDto(count.CountId, count.Status, null, null, Array.Empty<int>()); return new CountPostResultDto(count.CountId, count.Status, null, null, Array.Empty<int>());
} }
var reason = await _reasonCodes.Query().AsNoTracking() var reason = await _counts.GetAdjustmentReasonAsync(VarianceReasonCode, ct)
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422); ?? 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 — // A serial counted missing is written off (Scrapped); one found is brought back InStock —
@@ -257,6 +214,8 @@ public sealed class CountService : ICountService
}).ToList() }).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, _ => var (adjustment, refs) = await _adjustments.SubmitAsync(request, _ =>
{ {
count.Status = CountStatus.Posted; count.Status = CountStatus.Posted;
+53 -120
View File
@@ -7,114 +7,53 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in ICustomerRepository
namespace ERPCore.Services; 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 public sealed class CustomerService : ICustomerService
{ {
private readonly IRepository<Customer> _customers; private readonly ICustomerRepository _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
private readonly IRepository<SalesConfig> _salesConfig;
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesInvoicePayment> _invoicePayments;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary> /// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
private const string CashCustomerCode = "CUST-CASH"; private const string CashCustomerCode = "CUST-CASH";
public CustomerService( public CustomerService(ICustomerRepository customers, IBranchConfigService branchConfig, IUnitOfWork uow)
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
IRepository<SalesConfig> salesConfig, IRepository<SalesInvoice> invoices,
IRepository<SalesInvoicePayment> invoicePayments, IUnitOfWork uow)
{ {
_customers = customers; _customers = customers;
_warehouses = warehouses;
_branchConfig = branchConfig; _branchConfig = branchConfig;
_salesConfig = salesConfig;
_invoices = invoices;
_invoicePayments = invoicePayments;
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<CustomerDto>> ListAsync( public async Task<PagedResponse<CustomerDto>> ListAsync(
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default) PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default)
{ {
var q = _customers.Query().AsNoTracking(); var criteria = new CustomerListCriteria(query.Q, status, customerType, includeCashCustomer, branchId, CashCustomerCode);
var cashCustomerId = await _salesConfig.Query().AsNoTracking() var (rows, total) = await _customers.ListPagedAsync(criteria, query.Skip, query.PageSize, ct);
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => c.CashCustomerId) // Each customer's receivable position — the unpaid balance and count of their Posted sales
.FirstOrDefaultAsync(ct); // invoices, and their latest invoice payment — for just the customers on this page.
// The generic cash customer is a system default for walk-in / one-time buyers, not a registered var receivables = await _customers.GetReceivablesAsync(rows.Select(r => r.CustomerId).ToList(), ct);
// customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names). var items = rows.Select(c =>
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 term = query.Q.Trim(); var dto = Map(c);
q = q.Where(c => EF.Functions.Like(c.Name, $"%{term}%") return receivables.TryGetValue(c.CustomerId, out var r)
|| EF.Functions.Like(c.CustomerCode, $"%{term}%") ? dto with { OutstandingAmount = r.OutstandingAmount, OutstandingInvoiceCount = r.OutstandingInvoiceCount, LastPaymentDate = r.LastPaymentDate }
|| (c.DisplayName != null && EF.Functions.Like(c.DisplayName, $"%{term}%"))); : dto;
}
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
};
}).ToList(); }).ToList();
return PagedResponse<CustomerDto>.Create(items, query.Page, query.PageSize, total);
} }
public async Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default) public async Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default)
{ {
var customer = await _customers.Query().AsNoTracking() var customer = await _customers.GetForReadAsync(customerId, ct);
.FirstOrDefaultAsync(c => c.CustomerId == customerId, ct);
return customer is null ? null : new ETagged<CustomerDto>(Map(customer), customer.RowVersion); return customer is null ? null : new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
} }
@@ -122,26 +61,25 @@ public sealed class CustomerService : ICustomerService
{ {
var code = request.CustomerCode.Trim(); var code = request.CustomerCode.Trim();
var name = request.Name.Trim(); var name = request.Name.Trim();
var fields = await GetFieldSwitchesAsync(ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
if (await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower(), ct)) var customer = await _uow.ExecuteInTransactionAsync(async token =>
{
if (await _customers.CodeExistsAsync(code, ct: token))
throw new ConflictException($"A customer code '{code}' already exists."); throw new ConflictException($"A customer code '{code}' already exists.");
if (!string.IsNullOrWhiteSpace(request.Email)) if (!string.IsNullOrWhiteSpace(request.Email))
{ {
var email = request.Email.Trim(); var email = request.Email.Trim();
if (await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == email.ToLower(), ct)) if (await _customers.EmailExistsAsync(email, ct: token))
throw new ConflictException($"A customer with email '{email}' already exists."); throw new ConflictException($"A customer with email '{email}' already exists.");
} }
if (request.DefaultWarehouseId is not null) if (request.DefaultWarehouseId is { } warehouseId && !await _customers.WarehouseExistsAsync(warehouseId, token))
{ throw new NotFoundException($"Warehouse {warehouseId} was not found.");
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 entity = new Customer
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var customer = new Customer
{ {
CustomerCode = code, CustomerCode = code,
CustomerType = request.CustomerType, CustomerType = request.CustomerType,
@@ -161,9 +99,9 @@ public sealed class CustomerService : ICustomerService
BranchId = branchId, BranchId = branchId,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
await _customers.AddAsync(entity, token);
await _customers.AddAsync(customer, ct); return entity;
await _uow.SaveChangesAsync(ct); }, ct);
return new ETagged<CustomerDto>(Map(customer), customer.RowVersion); return new ETagged<CustomerDto>(Map(customer), customer.RowVersion);
} }
@@ -178,28 +116,30 @@ public sealed class CustomerService : ICustomerService
var code = request.CustomerCode.Trim(); var code = request.CustomerCode.Trim();
var name = request.Name.Trim(); var name = request.Name.Trim();
// A field switched off in Sales Configuration keeps whatever is stored; only a *different* value is refused.
var fields = await GetFieldSwitchesAsync(ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
try
{
await _uow.ExecuteInTransactionAsync(async token =>
{
if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal) if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal)
&& await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower() && c.CustomerId != customerId, ct)) && await _customers.CodeExistsAsync(code, customerId, token))
throw new ConflictException($"A customer code '{code}' already exists."); throw new ConflictException($"A customer code '{code}' already exists.");
if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase) if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.Email)
&& await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == request.Email!.Trim().ToLower() && c.CustomerId != customerId, ct)) && await _customers.EmailExistsAsync(request.Email.Trim(), customerId, token))
throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists."); throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists.");
if (request.DefaultWarehouseId is not null) if (request.DefaultWarehouseId is { } warehouseId && !await _customers.WarehouseExistsAsync(warehouseId, token))
{ throw new NotFoundException($"Warehouse {warehouseId} was not found.");
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.CustomerCode = code;
customer.CustomerType = request.CustomerType; customer.CustomerType = request.CustomerType;
customer.Name = name; customer.Name = name;
customer.DisplayName = Normalize(request.DisplayName); 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.Phone = Gate(fields.Phone, request.Phone, customer.Phone, "phone numbers");
customer.Email = Gate(fields.Email, request.Email, customer.Email, "email addresses"); customer.Email = Gate(fields.Email, request.Email, customer.Email, "email addresses");
customer.AddressLine1 = Gate(fields.Address, request.AddressLine1, customer.AddressLine1, "addresses"); customer.AddressLine1 = Gate(fields.Address, request.AddressLine1, customer.AddressLine1, "addresses");
@@ -210,12 +150,9 @@ public sealed class CustomerService : ICustomerService
customer.CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, customer.CreditLimit, "credit limits"); customer.CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, customer.CreditLimit, "credit limits");
customer.CreditDays = Gate(fields.CreditDays, request.CreditDays, customer.CreditDays, "credit days"); customer.CreditDays = Gate(fields.CreditDays, request.CreditDays, customer.CreditDays, "credit days");
customer.DefaultWarehouseId = request.DefaultWarehouseId; customer.DefaultWarehouseId = request.DefaultWarehouseId;
customer.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); customer.BranchId = branchId;
customer.UpdatedAt = DateTime.UtcNow; customer.UpdatedAt = DateTime.UtcNow;
}, ct);
try
{
await _uow.SaveChangesAsync(ct);
} }
catch (DbUpdateConcurrencyException) catch (DbUpdateConcurrencyException)
{ {
@@ -230,9 +167,12 @@ public sealed class CustomerService : ICustomerService
var customer = await _customers.GetByIdAsync(customerId, ct) var customer = await _customers.GetByIdAsync(customerId, ct)
?? throw new NotFoundException($"Customer {customerId} was not found."); ?? throw new NotFoundException($"Customer {customerId} was not found.");
await _uow.ExecuteInTransactionAsync(_ =>
{
customer.Status = status; customer.Status = status;
customer.UpdatedAt = DateTime.UtcNow; customer.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
} }
private static CustomerDto Map(Customer c) => new( 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(); 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> /// <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<CustomerFieldSwitches> GetFieldSwitchesAsync(CancellationToken ct) =>
await _customers.GetFieldSwitchesAsync(ct)
private async Task<FieldSwitches> GetFieldSwitchesAsync(CancellationToken ct) => ?? new CustomerFieldSwitches(true, true, true, true, true, true, true);
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);
/// <summary>On: the (trimmed) requested value. Off: the stored value is kept; a different value is a 422 CONFIG_DISABLED /// <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> /// (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;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts; using ERPCore.Dtos.Accounts;
using ERPCore.Dtos.Reports; using ERPCore.Dtos.Reports;
@@ -7,49 +6,32 @@ using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
/// <summary> /// <summary>
/// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Sales documents are stamped in /// 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 /// 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 /// <c>[start, end)</c> window. The database does the filtering and returns slim projections
/// rows are bucketed into local days here, since MariaDB can't group by a time-zone-shifted date /// (<see cref="IDayEndReportRepository"/>); the rows are bucketed into local days here, since MariaDB
/// portably and a ≤ 92-day window of slim rows is small. Cost of goods sold is the stock ledger's /// can't group by a time-zone-shifted date portably and a ≤ 92-day window of slim rows is small. Cost of
/// Out value per sale document — the same source <see cref="SalesDayEndService"/> posts COGS from. /// goods sold is the stock ledger's Out value per sale document — the same source <see cref="SalesDayEndService"/>
/// The cash figures (net cash, deposits, cash in hand) come from the Bank Deposit module's /// 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. /// <see cref="ICashInHandService"/> (docs/BANK_DEPOSIT_MODULE_PLAN.md §9); this report owns none of them.
/// </summary> /// </summary>
public sealed class DayEndReportService : IDayEndReportService public sealed class DayEndReportService : IDayEndReportService
{ {
private const int MaxRangeDays = 92; private const int MaxRangeDays = 92;
private const int IdChunk = 1000;
private readonly IRepository<SalesSlip> _slips; private readonly IDayEndReportRepository _report;
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 ICashInHandService _cash; private readonly ICashInHandService _cash;
private readonly ICurrentBranchContext _currentBranch; private readonly ICurrentBranchContext _currentBranch;
private readonly BusinessDayClock _clock; private readonly BusinessDayClock _clock;
public DayEndReportService( public DayEndReportService(
IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesInvoice> invoices, IDayEndReportRepository report, ICashInHandService cash, ICurrentBranchContext currentBranch, BusinessDayClock clock)
IRepository<SalesInvoicePayment> invoicePayments, IRepository<StockLedger> ledger,
IRepository<Expense> expenses, IRepository<BankDeposit> deposits, ICashInHandService cash,
ICurrentBranchContext currentBranch, BusinessDayClock clock)
{ {
_slips = slips; _report = report;
_bundles = bundles;
_invoices = invoices;
_invoicePayments = invoicePayments;
_ledger = ledger;
_expenses = expenses;
_deposits = deposits;
_cash = cash; _cash = cash;
_currentBranch = currentBranch; _currentBranch = currentBranch;
_clock = clock; _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)) 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(); .ToList();
var expenses = await PostedExpenses(date, date, branch) var expenses = data.Expenses
.OrderBy(e => e.ExpenseId) .Select(e => new DayEndReportExpenseDto(e.ExpenseId, e.DocNo, e.CategoryName, e.TitleName, e.Payee, e.Amount, e.PaidAmount))
.Select(e => new DayEndReportExpenseDto( .ToList();
e.ExpenseId, e.DocNo, e.Category!.Name, e.Title!.Name, e.Payee, e.Amount, e.PaidAmount))
.ToListAsync(ct);
var depositsQuery = _deposits.Query().AsNoTracking() var deposits = (await _report.ListPostedDepositsAsync(date, branch, ct))
.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)
.Select(d => new DayEndReportDepositDto( .Select(d => new DayEndReportDepositDto(
d.BankDepositId, d.DocNo, d.ToBankAccountName, d.ToBankName, d.ToAccountNumber, d.DepositSlipNo, d.DepositDate, d.Amount)) 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); 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, 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); 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) private async Task<Loaded> LoadAsync(DateOnly from, DateOnly to, int? branch, CancellationToken ct)
{ {
var start = _clock.ToUtc(from); var start = _clock.ToUtc(from);
var end = _clock.ToUtc(to.AddDays(1)); var end = _clock.ToUtc(to.AddDays(1));
var slipsQuery = _slips.Query().AsNoTracking() var slips = await _report.ListPostedSlipsAsync(start, end, branch, ct);
.Where(s => s.Status == SalesSlipStatus.Posted && s.SlipDate >= start && s.SlipDate < end); var bundles = await _report.ListPostedBundlesAsync(start, end, branch, ct);
if (branch is not null) slipsQuery = slipsQuery.Where(s => s.Warehouse!.BranchId == branch); var invoices = await _report.ListPostedInvoicesAsync(start, end, branch, ct);
var slips = await slipsQuery var payments = await _report.ListInvoicePaymentsAsync(start, end, branch, ct);
.Select(s => new { s.SalesSlipId, s.SlipNo, s.SlipDate, s.CustomerSnapshotName, s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount })
.ToListAsync(ct);
var bundlesQuery = _bundles.Query().AsNoTracking() var slipCost = await _report.GetCostByDocAsync(DocumentTypes.SalesSlip, slips.Select(s => s.SalesSlipId), ct);
.Where(b => b.Status == BundleSaleStatus.Posted && b.BundleDate >= start && b.BundleDate < end); var bundleCost = await _report.GetCostByDocAsync(DocumentTypes.BundleSale, bundles.Select(b => b.BundleSaleId), ct);
if (branch is not null) bundlesQuery = bundlesQuery.Where(b => b.Warehouse!.BranchId == branch); var invoiceCost = await _report.GetCostByDocAsync(DocumentTypes.SalesInvoice, invoices.Select(i => i.SalesInvoiceId), ct);
var bundles = await bundlesQuery
.Select(b => new { b.BundleSaleId, b.BundleNo, b.BundleDate, b.CustomerSnapshotName, b.GrandTotal, b.CashAmount, b.CardAmount })
.ToListAsync(ct);
var invoicesQuery = _invoices.Query().AsNoTracking() var expenses = await _report.ListPostedExpensesAsync(from, to, branch, ct);
.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);
// Branch is already the effective one, so the cash service scopes it the same way. // 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); 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); var sales = new List<Sale>(slips.Count + bundles.Count + invoices.Count);
sales.AddRange(slips.Select(s => new Sale( 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), s.GrandTotal, s.PaidAmount, s.CashAmount, s.CardAmount, 0m, PaidBy(s.CashAmount, s.CardAmount, 0m, null),
slipCost.GetValueOrDefault(s.SalesSlipId)))); slipCost.GetValueOrDefault(s.SalesSlipId))));
sales.AddRange(bundles.Select(b => new Sale( 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), b.GrandTotal, b.GrandTotal, b.CashAmount, b.CardAmount, 0m, PaidBy(b.CashAmount, b.CardAmount, 0m, null),
bundleCost.GetValueOrDefault(b.BundleSaleId)))); bundleCost.GetValueOrDefault(b.BundleSaleId))));
foreach (var i in invoices) foreach (var i in invoices)
@@ -175,7 +122,7 @@ public sealed class DayEndReportService : IDayEndReportService
var other = others.Sum(p => p.Amount); var other = others.Sum(p => p.Amount);
var otherMethods = others.Select(p => p.PaymentMethod).Distinct().ToList(); var otherMethods = others.Select(p => p.PaymentMethod).Distinct().ToList();
sales.Add(new Sale( 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, i.NetPayable, i.PaidAmount, cash, card, other,
PaidBy(cash, card, other, otherMethods.Count == 1 ? otherMethods[0] : null), PaidBy(cash, card, other, otherMethods.Count == 1 ? otherMethods[0] : null),
invoiceCost.GetValueOrDefault(i.SalesInvoiceId))); invoiceCost.GetValueOrDefault(i.SalesInvoiceId)));
@@ -195,23 +142,7 @@ public sealed class DayEndReportService : IDayEndReportService
days.Add(acc.ToDto(d)); days.Add(acc.ToDto(d));
} }
return new Loaded(days, sales); return new Loaded(days, sales, expenses);
}
/// <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;
} }
private static string PaidBy(decimal cash, decimal card, decimal other, SalesPaymentMethod? singleOther) 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"; 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); 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> /// <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.Gl;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -18,11 +17,11 @@ namespace ERPCore.Services;
/// all sharing that journal number. Same call-GL-before-commit pattern as /// all sharing that journal number. Same call-GL-before-commit pattern as
/// <see cref="PurchaseInvoicePaymentService"/>, so a rejected/unreachable GL post rolls the /// <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. /// 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> /// </summary>
public sealed class ExpensePaymentService : IExpensePaymentService public sealed class ExpensePaymentService : IExpensePaymentService
{ {
private readonly IRepository<Expense> _expenses; private readonly IExpensePaymentRepository _payments;
private readonly IRepository<ExpensePayment> _payments;
private readonly ExpenseService _expenseService; private readonly ExpenseService _expenseService;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
@@ -30,10 +29,9 @@ public sealed class ExpensePaymentService : IExpensePaymentService
private readonly IGlAccountConfigService _glAccounts; private readonly IGlAccountConfigService _glAccounts;
public ExpensePaymentService( public ExpensePaymentService(
IRepository<Expense> expenses, IRepository<ExpensePayment> payments, ExpenseService expenseService, IExpensePaymentRepository payments, ExpenseService expenseService,
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts) IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
{ {
_expenses = expenses;
_payments = payments; _payments = payments;
_expenseService = expenseService; _expenseService = expenseService;
_gl = gl; _gl = gl;
@@ -93,13 +91,10 @@ public sealed class ExpensePaymentService : IExpensePaymentService
public async Task<IReadOnlyList<ExpensePaymentDto>> ListAsync(int expenseId, CancellationToken ct = default) 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."); throw new NotFoundException($"Expense {expenseId} was not found.");
var payments = await _payments.Query().AsNoTracking() var payments = await _payments.ListForExpenseAsync(expenseId, ct);
.Where(p => p.ExpenseId == expenseId)
.OrderByDescending(p => p.ExpensePaymentId)
.ToListAsync(ct);
return payments.Select(ExpenseService.MapPayment).ToList(); return payments.Select(ExpenseService.MapPayment).ToList();
} }
+34 -77
View File
@@ -10,7 +10,7 @@ using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl; using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IExpenseRepository
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -20,13 +20,12 @@ namespace ERPCore.Services;
/// real GL journal entry posted before commit — same atomicity approach as /// real GL journal entry posted before commit — same atomicity approach as
/// <see cref="PurchaseInvoiceService.PostAsync"/>, so a GL rejection rolls the post back. /// <see cref="PurchaseInvoiceService.PostAsync"/>, so a GL rejection rolls the post back.
/// Payments settle the accrual (<see cref="ExpensePaymentService"/>). Cancelling a Posted /// 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> /// </summary>
public sealed class ExpenseService : IExpenseService public sealed class ExpenseService : IExpenseService
{ {
private readonly IRepository<Expense> _expenses; private readonly IExpenseRepository _expenses;
private readonly IRepository<ExpenseTitle> _titles;
private readonly IRepository<Vendor> _vendors;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
@@ -35,13 +34,10 @@ public sealed class ExpenseService : IExpenseService
private readonly IGlAccountConfigService _glAccounts; private readonly IGlAccountConfigService _glAccounts;
public ExpenseService( public ExpenseService(
IRepository<Expense> expenses, IRepository<ExpenseTitle> titles, IRepository<Vendor> vendors, IExpenseRepository expenses, ICurrentUser currentUser, INumberSequenceService numbers, IBranchConfigService branchConfig,
ICurrentUser currentUser, INumberSequenceService numbers, IBranchConfigService branchConfig,
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
_expenses = expenses; _expenses = expenses;
_titles = titles;
_vendors = vendors;
_currentUser = currentUser; _currentUser = currentUser;
_numbers = numbers; _numbers = numbers;
_branchConfig = branchConfig; _branchConfig = branchConfig;
@@ -51,31 +47,9 @@ public sealed class ExpenseService : IExpenseService
} }
public async Task<PagedResponse<ExpenseSummaryDto>> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default) 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); var today = DateOnly.FromDateTime(DateTime.UtcNow);
q = q.Where(x => x.Status == ExpenseStatus.Posted && x.BalanceAmount > 0 && x.DueDate != null && x.DueDate < today); var (rows, total) = await _expenses.ListPagedAsync(query.Q, filter, today, query.Skip, query.PageSize, ct);
}
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);
return PagedResponse<ExpenseSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); 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); var vendorName = await ValidateVendorAsync(request.VendorId, ct);
ValidateDates(request); ValidateDates(request);
if (request.BranchId > 0 && request.BranchId != expense.BranchId) var branchId = request.BranchId > 0 && request.BranchId != expense.BranchId
expense.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); ? await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct)
: expense.BranchId;
try
{
await _uow.ExecuteInTransactionAsync(_ =>
{
expense.BranchId = branchId;
Apply(expense, request, vendorName); Apply(expense, request, vendorName);
expense.Category = title.Category; expense.Category = title.Category;
expense.Title = title; expense.Title = title;
expense.UpdatedAt = DateTime.UtcNow; 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); return new ETagged<ExpenseDto>(Map(expense), expense.RowVersion);
} }
public async Task DeleteAsync(int expenseId, CancellationToken ct = default) 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."); ?? throw new NotFoundException($"Expense {expenseId} was not found.");
EnsureDraft(expense, "deleted"); EnsureDraft(expense, "deleted");
await _uow.ExecuteInTransactionAsync(_ =>
{
_expenses.Remove(expense); _expenses.Remove(expense);
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
} }
public async Task<ExpenseDto> PostAsync(int expenseId, CancellationToken ct = default) public async Task<ExpenseDto> PostAsync(int expenseId, CancellationToken ct = default)
@@ -251,22 +240,10 @@ public sealed class ExpenseService : IExpenseService
if (end < start) if (end < start)
throw new DomainException(ErrorCodes.Validation, "'to' must not be before 'from'.", 400); 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 _expenses.ListPostedInRangeAsync(start, end, ct);
var outstanding = await _expenses.ListOutstandingAsync(ct);
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 overdue = outstanding.Where(x => x.DueDate != null && x.DueDate < today).ToList(); 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 var categories = inRange
.GroupBy(x => new { x.ExpenseCategoryId, x.CategoryName, x.CategorySort }) .GroupBy(x => new { x.ExpenseCategoryId, x.CategoryName, x.CategorySort })
@@ -286,22 +263,14 @@ public sealed class ExpenseService : IExpenseService
categories); 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) internal Task<Expense?> LoadAsync(int expenseId, bool tracking, CancellationToken ct)
{ => _expenses.GetWithDetailsAsync(expenseId, tracking, 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);
}
private async Task<ExpenseTitle> ValidateTitleAsync(ExpenseRequestBase request, CancellationToken ct) private async Task<ExpenseTitle> ValidateTitleAsync(ExpenseRequestBase request, CancellationToken ct)
{ {
// Tracked on purpose: UpdateAsync assigns this to the tracked expense's navigations, and // Tracked on purpose (see IExpenseRepository.GetTitleWithCategoryAsync).
// identity resolution hands back the instance the expense already loaded. var title = await _expenses.GetTitleWithCategoryAsync(request.ExpenseTitleId, ct)
var title = await _titles.Query().Include(t => t.Category)
.FirstOrDefaultAsync(t => t.ExpenseTitleId == request.ExpenseTitleId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Expense title {request.ExpenseTitleId} does not exist.", 422); ?? throw new DomainException(ErrorCodes.Validation, $"Expense title {request.ExpenseTitleId} does not exist.", 422);
if (title.ExpenseCategoryId != request.ExpenseCategoryId) if (title.ExpenseCategoryId != request.ExpenseCategoryId)
@@ -315,7 +284,7 @@ public sealed class ExpenseService : IExpenseService
private async Task<string?> ValidateVendorAsync(int? vendorId, CancellationToken ct) private async Task<string?> ValidateVendorAsync(int? vendorId, CancellationToken ct)
{ {
if (vendorId is null) return null; 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); ?? 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); 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(); private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
internal static ExpensePaymentStatus DerivePaymentStatus(decimal paidAmount, decimal balanceAmount) => internal static ExpensePaymentStatus DerivePaymentStatus(decimal paidAmount, decimal balanceAmount) =>
+33 -35
View File
@@ -5,7 +5,6 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -15,90 +14,92 @@ namespace ERPCore.Services;
/// deactivated, and a title's GL account code can be corrected — while user-added titles /// 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 /// are fully editable. Nothing is hard-deleted: expenses reference titles, so retiring one
/// means setting it Inactive (it stops being offered on new expenses). /// 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> /// </summary>
public sealed class ExpenseSetupService : IExpenseSetupService public sealed class ExpenseSetupService : IExpenseSetupService
{ {
private readonly IRepository<ExpenseCategory> _categories; private readonly IExpenseSetupRepository _setup;
private readonly IRepository<ExpenseTitle> _titles;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public ExpenseSetupService(IRepository<ExpenseCategory> categories, IRepository<ExpenseTitle> titles, IUnitOfWork uow) public ExpenseSetupService(IExpenseSetupRepository setup, IUnitOfWork uow)
{ {
_categories = categories; _setup = setup;
_titles = titles;
_uow = uow; _uow = uow;
} }
public async Task<IReadOnlyList<ExpenseCategoryDto>> ListAsync(bool includeInactive, CancellationToken ct = default) public async Task<IReadOnlyList<ExpenseCategoryDto>> ListAsync(bool includeInactive, CancellationToken ct = default)
{ {
var categories = await _categories.Query().AsNoTracking() var categories = await _setup.ListWithTitlesAsync(includeInactive, ct);
.Include(c => c.Titles)
.Where(c => includeInactive || c.Status == EntityStatus.Active)
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name)
.ToListAsync(ct);
return categories.Select(c => Map(c, includeInactive)).ToList(); return categories.Select(c => Map(c, includeInactive)).ToList();
} }
public async Task<ExpenseCategoryDto> UpdateCategoryAsync(int expenseCategoryId, UpdateExpenseCategoryRequest request, CancellationToken ct = default) public async Task<ExpenseCategoryDto> UpdateCategoryAsync(int expenseCategoryId, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
{ {
var category = await _categories.Query().Include(c => c.Titles) var category = await _setup.GetWithTitlesForUpdateAsync(expenseCategoryId, ct)
.FirstOrDefaultAsync(c => c.ExpenseCategoryId == expenseCategoryId, ct)
?? throw new NotFoundException($"Expense category {expenseCategoryId} was not found."); ?? throw new NotFoundException($"Expense category {expenseCategoryId} was not found.");
var name = request.Name.Trim(); var name = request.Name.Trim();
if (name != category.Name && category.IsSystem)
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{category.Name}' is a built-in category and cannot be renamed.", 409);
await _uow.ExecuteInTransactionAsync(async token =>
{
if (name != category.Name) if (name != category.Name)
{ {
if (category.IsSystem) if (await _setup.CategoryNameExistsAsync(name, expenseCategoryId, token))
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); throw new DomainException(ErrorCodes.DuplicateExpenseCategory, $"An expense category named '{name}' already exists.", 409);
category.Name = name; category.Name = name;
} }
category.Status = request.Status; category.Status = request.Status;
category.UpdatedAt = DateTime.UtcNow; category.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); }, ct);
return Map(category, includeInactive: true); return Map(category, includeInactive: true);
} }
public async Task<ExpenseTitleDto> CreateTitleAsync(int expenseCategoryId, CreateExpenseTitleRequest request, CancellationToken ct = default) 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."); throw new NotFoundException($"Expense category {expenseCategoryId} was not found.");
var name = request.Name.Trim(); var name = request.Name.Trim();
await EnsureTitleNameIsUniqueAsync(expenseCategoryId, name, excludingTitleId: null, ct);
var nextSort = (await _titles.Query().Where(t => t.ExpenseCategoryId == expenseCategoryId) var title = await _uow.ExecuteInTransactionAsync(async token =>
.MaxAsync(t => (int?)t.SortOrder, ct) ?? 0) + 1; {
var title = new ExpenseTitle await EnsureTitleNameIsUniqueAsync(expenseCategoryId, name, excludingTitleId: null, token);
var entity = new ExpenseTitle
{ {
ExpenseCategoryId = expenseCategoryId, ExpenseCategoryId = expenseCategoryId,
Name = name, Name = name,
GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(), GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(),
IsSystem = false, IsSystem = false,
SortOrder = nextSort, SortOrder = await _setup.MaxTitleSortOrderAsync(expenseCategoryId, token) + 1,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
await _titles.AddAsync(title, ct); await _setup.AddTitleAsync(entity, token);
await _uow.SaveChangesAsync(ct); return entity;
}, ct);
return Map(title); return Map(title);
} }
public async Task<ExpenseTitleDto> UpdateTitleAsync(int expenseTitleId, UpdateExpenseTitleRequest request, CancellationToken ct = default) 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."); ?? throw new NotFoundException($"Expense title {expenseTitleId} was not found.");
var name = request.Name.Trim(); var name = request.Name.Trim();
if (name != title.Name && title.IsSystem)
throw new DomainException(ErrorCodes.ExpenseSystemRowReadOnly, $"'{title.Name}' is a built-in title and cannot be renamed.", 409);
await _uow.ExecuteInTransactionAsync(async token =>
{
if (name != title.Name) if (name != title.Name)
{ {
if (title.IsSystem) await EnsureTitleNameIsUniqueAsync(title.ExpenseCategoryId, name, excludingTitleId: expenseTitleId, token);
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; title.Name = name;
} }
@@ -107,17 +108,14 @@ public sealed class ExpenseSetupService : IExpenseSetupService
title.GlExpenseAccountCode = request.GlExpenseAccountCode.Trim(); title.GlExpenseAccountCode = request.GlExpenseAccountCode.Trim();
title.Status = request.Status; title.Status = request.Status;
title.UpdatedAt = DateTime.UtcNow; title.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); }, ct);
return Map(title); return Map(title);
} }
private async Task EnsureTitleNameIsUniqueAsync(int expenseCategoryId, string name, int? excludingTitleId, CancellationToken ct) private async Task EnsureTitleNameIsUniqueAsync(int expenseCategoryId, string name, int? excludingTitleId, CancellationToken ct)
{ {
var duplicate = await _titles.Query().AnyAsync(t => if (await _setup.TitleNameExistsAsync(expenseCategoryId, name, excludingTitleId, ct))
t.ExpenseCategoryId == expenseCategoryId && t.Name == name
&& (excludingTitleId == null || t.ExpenseTitleId != excludingTitleId), ct);
if (duplicate)
throw new DomainException(ErrorCodes.DuplicateExpenseTitle, $"A title named '{name}' already exists in this category.", 409); 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.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using ERPCore.System.Time; using ERPCore.System.Time;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -19,27 +18,16 @@ namespace ERPCore.Services;
/// for PO-based receipts — 02-SECURITY C.3; over-receipt tolerance enforced). /// for PO-based receipts — 02-SECURITY C.3; over-receipt tolerance enforced).
/// Confirm posts FIFO layers + inbound ledger and updates PO receipts inside one /// 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 /// 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> /// </summary>
public sealed class GrnService : IGrnService public sealed class GrnService : IGrnService
{ {
// Phase 1: block any receipt beyond the PO line's open quantity (configurable later, NFR-10). // Phase 1: block any receipt beyond the PO line's open quantity (configurable later, NFR-10).
private const decimal OverReceiptTolerance = 0m; private const decimal OverReceiptTolerance = 0m;
private readonly IRepository<Grn> _grns; private readonly IGrnRepository _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 ISerialTrackingService _serialTracking; 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 IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
@@ -52,28 +40,12 @@ public sealed class GrnService : IGrnService
private readonly IProcurementConfigService _procurementConfig; private readonly IProcurementConfigService _procurementConfig;
public GrnService( public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines, IGrnRepository grns, ISerialTrackingService serialTracking, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
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,
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts, IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts,
IProcurementConfigService procurementConfig) IProcurementConfigService procurementConfig)
{ {
_grns = grns; _grns = grns;
_pos = pos;
_poLines = poLines;
_items = items;
_warehouses = warehouses;
_bins = bins;
_vendors = vendors;
_batches = batches;
_serials = serials;
_serialMovements = serialMovements;
_serialTracking = serialTracking; _serialTracking = serialTracking;
_layers = layers;
_ledger = ledger;
_taxRates = taxRates;
_fifo = fifo; _fifo = fifo;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
@@ -90,60 +62,17 @@ public sealed class GrnService : IGrnService
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId, PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId,
DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default) 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`). // Created date, as a business (Colombo) day: [start of `from`, start of the day after `to`).
if (from is DateOnly fromDay) var criteria = new GrnListCriteria(
{ query.Q, status, poId, vendorId, warehouseId, branchId,
var start = BusinessClock.StartOfDayUtc(fromDay); from is DateOnly fromDay ? BusinessClock.StartOfDayUtc(fromDay) : null,
q = q.Where(g => g.CreatedAt >= start); to is DateOnly toDay ? BusinessClock.StartOfDayUtc(toDay.AddDays(1)) : null,
} query.Sort);
if (to is DateOnly toDay) var (rows, total) = await _grns.ListPagedAsync(criteria, query.Skip, query.PageSize, ct);
{
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 flags = await _procurementConfig.GetFlagsAsync(ct); var flags = await _procurementConfig.GetFlagsAsync(ct);
var vendorIds = rows.Select(r => r.VendorId).Distinct().ToList(); var vendorMap = await _grns.GetVendorsAsync(rows.Select(r => r.VendorId).Distinct().ToList(), ct);
var vendorMap = await _vendors.Query().AsNoTracking() var poDocNos = await _grns.GetPoDocNosAsync(rows.Where(r => r.PoId != null).Select(r => r.PoId!.Value).Distinct().ToList(), ct);
.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 dtos = rows.Select(r => new GrnSummaryDto( var dtos = rows.Select(r => new GrnSummaryDto(
r.GrnId, r.DocNo, r.PoId, r.VendorId, r.WarehouseId, r.BranchId, r.Status, 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); 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) public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
{ {
var grn = await _grns.Query().AsNoTracking() var grn = await _grns.GetWithDetailsAsync(grnId, ct);
.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);
return grn is null ? null : Map(grn); return grn is null ? null : Map(grn);
} }
@@ -201,7 +102,7 @@ public sealed class GrnService : IGrnService
if (await _grnConfig.GetForcedWarehouseIdAsync(ct) is int forcedWarehouseId) if (await _grnConfig.GetForcedWarehouseIdAsync(ct) is int forcedWarehouseId)
request.WarehouseId = 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); throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
// Resolve vendor + PO context. // Resolve vendor + PO context.
@@ -209,8 +110,7 @@ public sealed class GrnService : IGrnService
int vendorId; int vendorId;
if (request.PoId is not null) if (request.PoId is not null)
{ {
po = await _pos.Query().AsNoTracking().Include(p => p.Lines) po = await _grns.GetPoWithLinesAsync(request.PoId.Value, ct)
.FirstOrDefaultAsync(p => p.PoId == request.PoId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422); ?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422);
if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived)) 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."); 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) if (request.VendorId is null)
throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422); 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); throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
vendorId = request.VendorId.Value; vendorId = request.VendorId.Value;
} }
@@ -241,9 +141,9 @@ public sealed class GrnService : IGrnService
foreach (var input in request.Lines) 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); ?? 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); 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 // 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. // 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. // 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 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); : 0m);
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero); 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) public async Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default)
{ {
var grn = await _grns.Query() var grn = await _grns.GetForConfirmAsync(grnId, ct)
.Include(g => g.Lines).ThenInclude(l => l.Serials)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found."); ?? throw new NotFoundException($"GRN {grnId} was not found.");
// Idempotent replay: an already-confirmed GRN returns its existing result. // 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) 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; 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) public async Task<ReleaseLineResultDto> ReleaseLineAsync(int grnId, int grnLineId, string action, CancellationToken ct = default)
{ {
var grn = await _grns.Query() var grn = await _grns.GetWithLinesForUpdateAsync(grnId, ct)
.Include(g => g.Lines)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found."); ?? throw new NotFoundException($"GRN {grnId} was not found.");
var line = grn.Lines.FirstOrDefault(l => l.GrnLineId == grnLineId) var line = grn.Lines.FirstOrDefault(l => l.GrnLineId == grnLineId)
@@ -461,23 +357,23 @@ public sealed class GrnService : IGrnService
if (line.HoldStatus != HoldStatus.OnHold) if (line.HoldStatus != HoldStatus.OnHold)
throw new ConflictException($"GRN line {grnLineId} is {line.HoldStatus}, not 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; line.HoldStatus = next;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
return new ReleaseLineResultDto(grnLineId, HoldStatus.Available); }, ct);
}
// Reject: the held stock stays exactly where it is — still on the layer, still return new ReleaseLineResultDto(grnLineId, next);
// 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);
} }
private async Task<Batch?> ResolveBatchAsync( private async Task<Batch?> ResolveBatchAsync(
@@ -490,7 +386,7 @@ public sealed class GrnService : IGrnService
var key = (item.ItemId, batch.BatchNo.Trim()); var key = (item.ItemId, batch.BatchNo.Trim());
if (cache.TryGetValue(key, out var cached)) return cached; 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) if (existing is not null)
{ {
cache[key] = existing; 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 }; 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; cache[key] = created;
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges 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) private async Task EnsureSerialsFreeAsync(int itemId, string sku, List<string> numbers, CancellationToken ct)
{ {
var taken = await _serials.Query().AsNoTracking() var taken = await _grns.ListTakenSerialsAsync(itemId, numbers, take: 5, ct);
.Where(s => s.ItemId == itemId && numbers.Contains(s.SerialNo))
.Select(s => s.SerialNo).Take(5).ToListAsync(ct);
if (taken.Count > 0) if (taken.Count > 0)
throw new DomainException(ErrorCodes.SerialDuplicate, throw new DomainException(ErrorCodes.SerialDuplicate,
$"Serial number(s) already exist for item {sku}: {string.Join(", ", taken)}.", 409); $"Serial number(s) already exist for item {sku}: {string.Join(", ", taken)}.", 409);
@@ -582,7 +476,8 @@ public sealed class GrnService : IGrnService
/// </summary> /// </summary>
private async Task<List<StockLayer>> ReceiveSerialsAsync(Grn grn, GrnLine line, decimal unitCost, DateTime now, CancellationToken ct) 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); await EnsureSerialsFreeAsync(item.ItemId, item.Sku, line.Serials.Select(s => s.SerialNo).ToList(), ct);
var warrantyMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null; var warrantyMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null;
@@ -601,9 +496,9 @@ public sealed class GrnService : IGrnService
CreatedAt = now, CreatedAt = now,
WarrantyPeriodMonths = warrantyMonths WarrantyPeriodMonths = warrantyMonths
}; };
await _serials.AddAsync(serial, ct); await _grns.AddSerialAsync(serial, ct);
captured.Serial = serial; // FK fixed up on SaveChanges 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( var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, 1m, unitCost, now, ct); 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) private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{ {
if (poId is null) return; 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; if (po is null) return;
po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty) 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) private async Task<PurchaseOrderStatus?> GetPoStatusAsync(int? poId, CancellationToken ct)
{ {
if (poId is null) return null; 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) private async Task<GrnConfirmResultDto> BuildConfirmResultAsync(Grn grn, CancellationToken ct)
{ {
var lineIds = grn.Lines.Select(l => l.GrnLineId).ToList(); var layers = await _grns.ListLayersForLinesAsync(grn.Lines.Select(l => l.GrnLineId).ToList(), ct);
var layers = await _layers.Query().AsNoTracking() var ledgerRefs = await _grns.ListInboundLedgerIdsAsync(grn.GrnId, ct);
.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);
return new GrnConfirmResultDto( return new GrnConfirmResultDto(
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty,
+48 -30
View File
@@ -5,7 +5,6 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -14,7 +13,8 @@ namespace ERPCore.Services;
/// content-type cross-check and a size cap, server-authoritative — narrowed to web image /// 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 /// 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 /// <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> /// </summary>
public sealed class ItemImageService : IItemImageService public sealed class ItemImageService : IItemImageService
{ {
@@ -29,18 +29,14 @@ public sealed class ItemImageService : IItemImageService
[".webp"] = "image/webp" [".webp"] = "image/webp"
}; };
private readonly IRepository<ItemImage> _images; private readonly IItemImageRepository _images;
private readonly IRepository<Item> _items;
private readonly IProductConfigService _config; private readonly IProductConfigService _config;
private readonly IFileStorageService _storage; private readonly IFileStorageService _storage;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public ItemImageService( public ItemImageService(IItemImageRepository images, IProductConfigService config, IFileStorageService storage, IUnitOfWork uow)
IRepository<ItemImage> images, IRepository<Item> items, IProductConfigService config,
IFileStorageService storage, IUnitOfWork uow)
{ {
_images = images; _images = images;
_items = items;
_config = config; _config = config;
_storage = storage; _storage = storage;
_uow = uow; _uow = uow;
@@ -49,11 +45,7 @@ public sealed class ItemImageService : IItemImageService
public async Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default) public async Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default)
{ {
await EnsureItemExistsAsync(itemId, ct); await EnsureItemExistsAsync(itemId, ct);
return await _images.Query().AsNoTracking() return (await _images.ListForItemAsync(itemId, ct)).Select(Map).ToList();
.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);
} }
public async Task<ItemImageDto> UploadAsync( public async Task<ItemImageDto> UploadAsync(
@@ -75,14 +67,18 @@ public sealed class ItemImageService : IItemImageService
throw new DomainException(ErrorCodes.FileTooLarge, throw new DomainException(ErrorCodes.FileTooLarge,
$"Image exceeds the maximum allowed size of {MaxSizeBytes / (1024 * 1024)} MB.", 413); $"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); // Fail fast on a full item before writing the file; re-checked inside the transaction below.
if (existing.Count >= MaxImagesPerItem) await EnsureRoomAsync(itemId, ct);
throw new DomainException(ErrorCodes.Validation,
$"An item can have at most {MaxImagesPerItem} images.", 422);
var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(content, fileName, contentType, ct); var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(content, fileName, contentType, ct);
var image = new ItemImage ItemImage image;
try
{
image = await _uow.ExecuteInTransactionAsync(async token =>
{
var sortOrders = await EnsureRoomAsync(itemId, token);
var entity = new ItemImage
{ {
ItemId = itemId, ItemId = itemId,
OriginalFileName = Path.GetFileName(fileName), OriginalFileName = Path.GetFileName(fileName),
@@ -90,22 +86,28 @@ public sealed class ItemImageService : IItemImageService
RelativePath = relativePath, RelativePath = relativePath,
ContentType = expectedContentType, ContentType = expectedContentType,
SizeBytes = sizeBytes, SizeBytes = sizeBytes,
SortOrder = existing.Count == 0 ? 0 : existing.Max() + 1, SortOrder = sortOrders.Count == 0 ? 0 : sortOrders.Max() + 1,
UploadedBy = actorUserId, UploadedBy = actorUserId,
UploadedAt = DateTime.UtcNow 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); return Map(image);
await _uow.SaveChangesAsync(ct);
return new ItemImageDto(image.ItemImageId, image.ItemId, image.OriginalFileName, image.ContentType,
image.SizeBytes, image.SortOrder, image.UploadedAt);
} }
public async Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default) public async Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default)
{ {
var image = await _images.Query().AsNoTracking() var image = await _images.GetForItemAsync(itemId, itemImageId, tracking: false, ct)
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}."); ?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
return (await _storage.OpenReadAsync(image.RelativePath, ct), image.ContentType); 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) public async Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default)
{ {
var image = await _images.Query() var image = await _images.GetForItemAsync(itemId, itemImageId, tracking: true, ct)
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}."); ?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
await _uow.ExecuteInTransactionAsync(_ =>
{
_images.Remove(image); _images.Remove(image);
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
// File removed only after the row is gone, so a failed save never orphans a row. // File removed only after the row is gone, so a failed save never orphans a row.
await _storage.DeleteAsync(image.RelativePath, ct); await _storage.DeleteAsync(image.RelativePath, ct);
} }
private async Task EnsureItemExistsAsync(int itemId, CancellationToken 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."); 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);
} }
+94 -231
View File
@@ -9,7 +9,7 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IItemRepository
namespace ERPCore.Services; 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 /// 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. /// item-type reference at all — see docs/10 Part C.9.
/// </para> /// </para>
/// Queries go through <see cref="IItemRepository"/>; every write runs inside
/// <see cref="IUnitOfWork.ExecuteInTransactionAsync{T}"/> like <see cref="BankDepositService"/>.
/// </summary> /// </summary>
public sealed class ItemService : IItemService public sealed class ItemService : IItemService
{ {
private readonly IRepository<Item> _items; private readonly IItemRepository _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 IProductConfigService _config; private readonly IProductConfigService _config;
private readonly INotificationService _notifications; private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public ItemService( public ItemService(IItemRepository items, IProductConfigService config, INotificationService notifications, IUnitOfWork uow)
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)
{ {
_approvalConfig = approvalConfig;
_items = items; _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; _config = config;
_notifications = notifications; _notifications = notifications;
_uow = uow; _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( public async Task<PagedResponse<ItemListItemDto>> ListAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, CancellationToken ct = default) 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 items = rows.Select(i => new ItemListItemDto(
{
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 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.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId, i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, 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.TaxRateId, i.TaxRate?.Name, i.TaxRate?.Percentage,
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice, i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit, i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status, i.Barcode)) i.Status, i.Barcode)).ToList();
.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( public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
@@ -125,89 +69,38 @@ public sealed class ItemService : IItemService
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock, TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var q = _items.Query().AsNoTracking(); 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);
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 itemIds = page.Select(i => i.ItemId).ToList(); var itemIds = page.Select(i => i.ItemId).ToList();
// Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse. // Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse.
var stockByItem = await _stockLayers.Query().AsNoTracking() var stockByItem = await _items.GetStockByItemAsync(itemIds, ct);
.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);
// On-hand per (item, warehouse), to compare against each item's reorder settings. // On-hand per (item, warehouse), to compare against each item's reorder settings.
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking() var onHandByItemWarehouse = await _items.GetOnHandByItemWarehouseAsync(itemIds, ct);
.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);
// Last confirmed purchase (GRN) per item, with its vendor — pulled in bulk and // 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. // reduced in memory rather than a "first per group" query EF can't always translate.
var grnCandidates = await _grnLines.Query().AsNoTracking() var lastPurchaseByItem = (await _items.ListConfirmedPurchasesAsync(itemIds, ct))
.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
.GroupBy(x => x.ItemId) .GroupBy(x => x.ItemId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.PostedAt).First()); .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), // 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. // read off the stock ledger — the one place every sale channel posts uniformly.
var cutoff = DateTime.UtcNow.AddDays(-30); var salesByItem = await _items.GetQtyOutSinceAsync(
var salesTypes = new[] { DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale }; itemIds, DateTime.UtcNow.AddDays(-30),
var salesByItem = await _stockLedger.Query().AsNoTracking() [DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale], ct);
.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 rows = page.Select(i => 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 => var isLowStock = i.ReorderSettings.Any(rs =>
onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint); onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint);
var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId); var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId);
return new ItemOverviewDto( return new ItemOverviewDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status, 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, lastPurchase?.PostedAt, lastPurchase?.VendorId, lastPurchase?.VendorName,
salesByItem.GetValueOrDefault(i.ItemId)); salesByItem.GetValueOrDefault(i.ItemId));
}).ToList(); }).ToList();
@@ -217,37 +110,25 @@ public sealed class ItemService : IItemService
public async Task<IReadOnlyList<ItemPurchasePriceDto>> GetPurchasePricesAsync(int itemId, int take = 5, CancellationToken ct = default) 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."); throw new NotFoundException($"Item {itemId} was not found.");
return await _grnLines.Query().AsNoTracking() return (await _items.ListPurchasePricesAsync(itemId, Math.Clamp(take, 1, 50), ct))
.Where(l => l.ItemId == itemId && l.Grn!.PostedAt != null) .Select(p => new ItemPurchasePriceDto(
.OrderByDescending(l => l.Grn!.PostedAt).ThenByDescending(l => l.GrnLineId) p.GrnId, p.GrnDocNo, p.PostedAt, p.VendorId, p.VendorName, p.WarehouseCode,
.Take(Math.Clamp(take, 1, 50)) p.Qty, p.UnitCost, p.DiscountPct, p.NetUnitCost, p.VatPct))
.Select(l => new ItemPurchasePriceDto( .ToList();
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);
} }
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default) public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
{ {
var item = await _items.Query().AsNoTracking() var item = await _items.GetWithDetailsAsync(itemId, tracking: false, ct);
.Include(i => i.ReorderSettings)
.Include(i => i.TaxRate)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion); return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
} }
public async Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default) 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); 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; var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync( await ValidateReferencesAsync(
@@ -259,7 +140,14 @@ public sealed class ItemService : IItemService
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit); var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null); var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null);
var item = new Item var item = await _uow.ExecuteInTransactionAsync(async token =>
{
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);
var entity = new Item
{ {
Sku = request.Sku.Trim(), Sku = request.Sku.Trim(),
Name = request.Name.Trim(), Name = request.Name.Trim(),
@@ -285,11 +173,12 @@ public sealed class ItemService : IItemService
Status = EntityStatus.Active, Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
await _items.AddAsync(entity, token);
return entity;
}, ct);
await _items.AddAsync(item, ct); if (item.TaxRateId is int taxRateId)
await _uow.SaveChangesAsync(ct); item.TaxRate = await _items.GetTaxRateAsync(taxRateId, ct);
if (item.TaxRateId is not null)
item.TaxRate = await _taxRates.Query().AsNoTracking().FirstAsync(t => t.TaxRateId == item.TaxRateId, ct);
await _notifications.RaiseAsync("item.created", "Item Created", $"Item {item.Sku} ({item.Name}) was created.", nameof(Item), item.ItemId, ct); 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( public async Task<ETagged<ItemDetailDto>> UpdateAsync(
int itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default) int itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default)
{ {
var item = await _items.Query() var item = await _items.GetWithDetailsAsync(itemId, tracking: true, ct)
.Include(i => i.ReorderSettings)
.Include(i => i.TaxRate)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found."); ?? throw new NotFoundException($"Item {itemId} was not found.");
if (item.RowVersion != expectedRowVersion) if (item.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412); 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); 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; var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync( 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 // 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 // of that history exists, changing it would silently reinterpret every one of those
// numbers (240 bottles becoming 240 cases), so it is frozen instead. // 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( throw new DomainException(
ErrorCodes.MasterInUse, ErrorCodes.MasterInUse,
$"Item {itemId} has stock history; its base UOM can no longer be changed.", 409); $"Item {itemId} has stock history; its base UOM can no longer be changed.", 409);
@@ -346,6 +225,17 @@ public sealed class ItemService : IItemService
var warrantyPeriodMonths = ValidateWarrantyPeriod( var warrantyPeriodMonths = ValidateWarrantyPeriod(
request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths); request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths);
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);
item.Sku = request.Sku.Trim(); item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim(); item.Name = request.Name.Trim();
item.Description = request.Description; item.Description = request.Description;
@@ -368,11 +258,14 @@ public sealed class ItemService : IItemService
item.ContentBaseUnit = contentBaseUnit; item.ContentBaseUnit = contentBaseUnit;
item.Barcode = barcode; item.Barcode = barcode;
item.UpdatedAt = DateTime.UtcNow; item.UpdatedAt = DateTime.UtcNow;
}, ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412);
}
await SaveGuardingConcurrencyAsync(ct); item.TaxRate = item.TaxRateId is int taxRateId ? await _items.GetTaxRateAsync(taxRateId, ct) : null;
item.TaxRate = item.TaxRateId is null
? null
: await _taxRates.Query().AsNoTracking().FirstAsync(t => t.TaxRateId == item.TaxRateId, ct);
await _notifications.RaiseAsync("item.updated", "Item Updated", $"Item {item.Sku} ({item.Name}) was updated.", nameof(Item), item.ItemId, ct); 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); return new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
} }
@@ -382,9 +275,12 @@ public sealed class ItemService : IItemService
var item = await _items.GetByIdAsync(itemId, ct) var item = await _items.GetByIdAsync(itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found."); ?? throw new NotFoundException($"Item {itemId} was not found.");
await _uow.ExecuteInTransactionAsync(_ =>
{
item.Status = status; item.Status = status;
item.UpdatedAt = DateTime.UtcNow; item.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
} }
public async Task<ItemReorderSettingsDto> UpdateReorderAsync( public async Task<ItemReorderSettingsDto> UpdateReorderAsync(
@@ -393,15 +289,15 @@ public sealed class ItemService : IItemService
if (request.Settings.Select(s => s.WarehouseId).Distinct().Count() != request.Settings.Count) if (request.Settings.Select(s => s.WarehouseId).Distinct().Count() != request.Settings.Count)
throw new DomainException(ErrorCodes.Validation, "Duplicate warehouseId in reorder settings.", 400); throw new DomainException(ErrorCodes.Validation, "Duplicate warehouseId in reorder settings.", 400);
var item = await _items.Query() var item = await _items.GetWithDetailsAsync(itemId, tracking: true, ct)
.Include(i => i.ReorderSettings)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found."); ?? throw new NotFoundException($"Item {itemId} was not found.");
foreach (var warehouseId in request.Settings.Select(s => s.WarehouseId)) 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); throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422);
await _uow.ExecuteInTransactionAsync(_ =>
{
// Full-replacement upsert (avoids delete+insert clashes on the unique index). // 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()) foreach (var stale in item.ReorderSettings.Where(r => request.Settings.All(s => s.WarehouseId != r.WarehouseId)).ToList())
item.ReorderSettings.Remove(stale); item.ReorderSettings.Remove(stale);
@@ -426,7 +322,8 @@ public sealed class ItemService : IItemService
} }
item.UpdatedAt = DateTime.UtcNow; item.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
var settings = item.ReorderSettings var settings = item.ReorderSettings
.OrderBy(r => r.WarehouseId) .OrderBy(r => r.WarehouseId)
@@ -435,15 +332,6 @@ public sealed class ItemService : IItemService
return new ItemReorderSettingsDto(settings); 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> /// <summary>
/// Validates every FK on an item write, and gates the optional ones on the product /// 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 /// 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, int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, int? taxRateId,
ProductConfigDto config, CancellationToken ct) 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); throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
if (subCategoryId is not null) if (subCategoryId is int subId)
{ {
if (!config.SubcategoriesEnabled) if (!config.SubcategoriesEnabled)
throw new DomainException( throw new DomainException(
ErrorCodes.ConfigDisabled, ErrorCodes.ConfigDisabled,
"Subcategories are disabled in the product configuration; subCategoryId must be null.", 422); "Subcategories are disabled in the product configuration; subCategoryId must be null.", 422);
var sub = await _subCategories.Query().AsNoTracking() var sub = await _items.GetSubCategoryAsync(subId, ct)
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct); ?? throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422);
if (sub is null)
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422);
if (sub.Status != EntityStatus.Active) if (sub.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422); throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422);
// The two FKs must agree, or the item would claim a category its subcategory // 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); $"Subcategory {subCategoryId} belongs to category {sub.CategoryId}, not {categoryId}.", 422);
} }
if (brandId is not null) if (brandId is int bId)
{ {
if (!config.BrandsEnabled) if (!config.BrandsEnabled)
throw new DomainException( throw new DomainException(
ErrorCodes.ConfigDisabled, ErrorCodes.ConfigDisabled,
"Brands are disabled in the product configuration; brandId must be null.", 422); "Brands are disabled in the product configuration; brandId must be null.", 422);
var brand = await _brands.Query().AsNoTracking() var brand = await _items.GetBrandAsync(bId, ct)
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct); ?? throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422);
if (brand is null)
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422);
if (brand.Status != EntityStatus.Active) if (brand.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} is inactive.", 422); 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); 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() var vendor = await _items.GetVendorAsync(vId, ct)
.FirstOrDefaultAsync(v => v.VendorId == defaultVendorId, ct); ?? throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} does not exist.", 422);
if (vendor is null)
throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} does not exist.", 422);
if (vendor.Status != EntityStatus.Active) if (vendor.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} is inactive.", 422); 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() var taxRate = await _items.GetTaxRateAsync(tId, ct)
.FirstOrDefaultAsync(t => t.TaxRateId == taxRateId, ct); ?? throw new DomainException(ErrorCodes.Validation, $"Tax rate {taxRateId} does not exist.", 422);
if (taxRate is null)
throw new DomainException(ErrorCodes.Validation, $"Tax rate {taxRateId} does not exist.", 422);
if (taxRate.Status != EntityStatus.Active) if (taxRate.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Tax rate {taxRateId} is inactive.", 422); throw new DomainException(ErrorCodes.Validation, $"Tax rate {taxRateId} is inactive.", 422);
} }
@@ -610,18 +490,6 @@ public sealed class ItemService : IItemService
return warrantyPeriodMonths; 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 /// <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 /// 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> /// 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) if (itemId is not null)
{ {
var batchForItem = await _batches.Query().AsNoTracking().Include(b => b.Item) var batchForItem = await _items.FindBatchByCodeAsync(code, itemId, ct);
.FirstOrDefaultAsync(b => b.ItemId == itemId && (b.Barcode == code || b.BatchNo == code), ct);
if (batchForItem is not null) return ToScanResult(batchForItem); if (batchForItem is not null) return ToScanResult(batchForItem);
var serialForItem = await _serials.Query().AsNoTracking().Include(s => s.Item) var serialForItem = await _items.FindSerialByCodeAsync(code, itemId, ct);
.FirstOrDefaultAsync(s => s.ItemId == itemId && (s.Barcode == code || s.SerialNo == code), ct);
if (serialForItem is not null) return ToScanResult(serialForItem); if (serialForItem is not null) return ToScanResult(serialForItem);
} }
var item = await _items.Query().AsNoTracking() var item = await _items.FindItemByCodeAsync(code, ct);
.FirstOrDefaultAsync(i => i.Barcode == code || i.Sku == code, ct);
if (item is not null) return ToScanResult(item); if (item is not null) return ToScanResult(item);
var serial = await _serials.Query().AsNoTracking().Include(s => s.Item) var serial = await _items.FindSerialByCodeAsync(code, ct: ct);
.FirstOrDefaultAsync(s => s.Barcode == code || s.SerialNo == code, ct);
if (serial is not null) return ToScanResult(serial); if (serial is not null) return ToScanResult(serial);
var batch = await _batches.Query().AsNoTracking().Include(b => b.Item) var batch = await _items.FindBatchByCodeAsync(code, ct: ct);
.FirstOrDefaultAsync(b => b.Barcode == code || b.BatchNo == code, ct);
if (batch is not null) return ToScanResult(batch); if (batch is not null) return ToScanResult(batch);
return null; return null;
+27 -32
View File
@@ -7,7 +7,7 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IItemTypeRepository
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -15,14 +15,15 @@ namespace ERPCore.Services;
/// Item type master service (FR-MD-10) — maintains the Color/Size/Material list that /// 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 /// <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 /// 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> /// </summary>
public sealed class ItemTypeService : IItemTypeService public sealed class ItemTypeService : IItemTypeService
{ {
private readonly IRepository<ItemType> _itemTypes; private readonly IItemTypeRepository _itemTypes;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public ItemTypeService(IRepository<ItemType> itemTypes, IUnitOfWork uow) public ItemTypeService(IItemTypeRepository itemTypes, IUnitOfWork uow)
{ {
_itemTypes = itemTypes; _itemTypes = itemTypes;
_uow = uow; _uow = uow;
@@ -30,46 +31,35 @@ public sealed class ItemTypeService : IItemTypeService
public async Task<PagedResponse<ItemTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) public async Task<PagedResponse<ItemTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{ {
var q = _itemTypes.Query().AsNoTracking(); var (rows, total) = await _itemTypes.ListPagedAsync(query.Q, status, query.Skip, query.PageSize, ct);
if (!string.IsNullOrWhiteSpace(query.Q)) return PagedResponse<ItemTypeDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
{
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);
} }
public async Task<ETagged<ItemTypeDto>?> GetAsync(int itemTypeId, CancellationToken ct = default) public async Task<ETagged<ItemTypeDto>?> GetAsync(int itemTypeId, CancellationToken ct = default)
{ {
var itemType = await _itemTypes.Query().AsNoTracking() var itemType = await _itemTypes.GetForReadAsync(itemTypeId, ct);
.FirstOrDefaultAsync(t => t.ItemTypeId == itemTypeId, ct);
return itemType is null ? null : new ETagged<ItemTypeDto>(Map(itemType), itemType.RowVersion); return itemType is null ? null : new ETagged<ItemTypeDto>(Map(itemType), itemType.RowVersion);
} }
public async Task<ETagged<ItemTypeDto>> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default) public async Task<ETagged<ItemTypeDto>> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default)
{ {
var name = request.Name.Trim(); var name = request.Name.Trim();
if (await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower(), ct))
var itemType = await _uow.ExecuteInTransactionAsync(async token =>
{
if (await _itemTypes.NameExistsAsync(name, ct: token))
throw new ConflictException($"An item type named '{name}' already exists."); throw new ConflictException($"An item type named '{name}' already exists.");
var itemType = new ItemType var entity = new ItemType
{ {
Name = name, Name = name,
IsMeasurable = request.IsMeasurable, IsMeasurable = request.IsMeasurable,
Status = EntityStatus.Active, Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
await _itemTypes.AddAsync(entity, token);
await _itemTypes.AddAsync(itemType, ct); return entity;
await _uow.SaveChangesAsync(ct); }, ct);
return new ETagged<ItemTypeDto>(Map(itemType), itemType.RowVersion); return new ETagged<ItemTypeDto>(Map(itemType), itemType.RowVersion);
} }
@@ -84,8 +74,13 @@ public sealed class ItemTypeService : IItemTypeService
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412); throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412);
var name = request.Name.Trim(); var name = request.Name.Trim();
try
{
await _uow.ExecuteInTransactionAsync(async token =>
{
if (!string.Equals(itemType.Name, name, StringComparison.Ordinal) if (!string.Equals(itemType.Name, name, StringComparison.Ordinal)
&& await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower() && t.ItemTypeId != itemTypeId, ct)) && await _itemTypes.NameExistsAsync(name, itemTypeId, token))
throw new ConflictException($"An item type named '{name}' already exists."); throw new ConflictException($"An item type named '{name}' already exists.");
// Renaming does not touch existing items: their SKUs already encode the values that // Renaming does not touch existing items: their SKUs already encode the values that
@@ -95,10 +90,7 @@ public sealed class ItemTypeService : IItemTypeService
// so let a name-only PUT silently clear the flag on every rename. // so let a name-only PUT silently clear the flag on every rename.
itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable; itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable;
itemType.UpdatedAt = DateTime.UtcNow; itemType.UpdatedAt = DateTime.UtcNow;
}, ct);
try
{
await _uow.SaveChangesAsync(ct);
} }
catch (DbUpdateConcurrencyException) catch (DbUpdateConcurrencyException)
{ {
@@ -113,9 +105,12 @@ public sealed class ItemTypeService : IItemTypeService
var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct) var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct)
?? throw new NotFoundException($"Item type {itemTypeId} was not found."); ?? throw new NotFoundException($"Item type {itemTypeId} was not found.");
await _uow.ExecuteInTransactionAsync(_ =>
{
itemType.Status = status; itemType.Status = status;
itemType.UpdatedAt = DateTime.UtcNow; itemType.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
} }
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt); 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.Repositories.Interfaces;
using ERPCore.Domain.Entities;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Services; namespace ERPCore.Services;
/// <summary> /// <summary>
/// Atomic document-number issuer. Uses a single <c>INSERT … ON DUPLICATE KEY UPDATE … /// Atomic document-number issuer (FR-X-03). The counter bump is one atomic upsert in
/// RETURNING</c> so concurrent issues for the same (docType, branchId, year) cannot get /// <see cref="INumberSequenceRepository.IncrementAsync"/>, enlisted in the caller's current transaction so the
/// the same number (the upsert holds an exclusive lock on the row until commit). Runs as a /// number commits or rolls back with the document. Numbering is per-branch (docs/BRANCH_ISOLATION_PLAN.md §5):
/// direct ADO.NET command enlisted in the DbContext's current transaction so it commits or /// the sequence key is (doc_type, branch_id, year) and the format includes the branch code, e.g.
/// 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.
/// <c>PO-BR-001-2026-00042</c>. /// <c>PO-BR-001-2026-00042</c>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// MariaDB 10.5+ supports <c>RETURNING</c> on an upsert, so this stays a single atomic /// Unlike the other services this one deliberately does NOT open its own
/// statement exactly as the PostgreSQL version was — which is what keeps the callers that /// <c>IUnitOfWork.ExecuteInTransactionAsync</c>: it is called from inside other services' transactions
/// run outside a UoW transaction safe. Do NOT "optimise" this into the one-statement /// (bank deposits, GRNs, expenses, …), so it joins whatever transaction is current instead of starting one.
/// <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> /// </remarks>
public sealed class NumberSequenceService : INumberSequenceService public sealed class NumberSequenceService : INumberSequenceService
{ {
private readonly ErpDbContext _db; private readonly INumberSequenceRepository _sequences;
private readonly IRepository<Branch> _branches;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
public NumberSequenceService(ErpDbContext db, IRepository<Branch> branches, IBranchConfigService branchConfig) public NumberSequenceService(INumberSequenceRepository sequences, IBranchConfigService branchConfig)
{ {
_db = db; _sequences = sequences;
_branches = branches;
_branchConfig = branchConfig; _branchConfig = branchConfig;
} }
@@ -56,38 +36,10 @@ public sealed class NumberSequenceService : INumberSequenceService
{ {
var year = DateTime.UtcNow.Year; var year = DateTime.UtcNow.Year;
var branchCode = await _branches.Query().AsNoTracking() var branchCode = await _sequences.GetBranchCodeAsync(branchId, ct)
.Where(b => b.BranchId == branchId).Select(b => b.Code).FirstOrDefaultAsync(ct)
?? throw new InvalidOperationException($"Branch {branchId} was not found — cannot issue a document number."); ?? throw new InvalidOperationException($"Branch {branchId} was not found — cannot issue a document number.");
var conn = _db.Database.GetDbConnection(); var next = await _sequences.IncrementAsync(docType, branchId, year, ct);
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);
return $"{docType}-{branchCode}-{year}-{next:D5}"; 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.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using ERPCore.System.Time; using ERPCore.System.Time;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -30,6 +29,8 @@ namespace ERPCore.Services;
/// check; this is an admin-only, rare operation and is not serialised. /// check; this is an admin-only, rare operation and is not serialised.
/// </para> /// </para>
/// Untracked items only: serial- and batch-tracked items need per-unit identities, which a plain quantity cannot give. /// 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> /// </summary>
public sealed class OpeningStockService : IOpeningStockService public sealed class OpeningStockService : IOpeningStockService
{ {
@@ -38,11 +39,7 @@ public sealed class OpeningStockService : IOpeningStockService
private const string SourceModule = "OPENING"; private const string SourceModule = "OPENING";
private const string JournalType = "OPENING_BALANCE"; private const string JournalType = "OPENING_BALANCE";
private readonly IRepository<OpeningBalanceRun> _runs; private readonly IOpeningStockRepository _runs;
private readonly IRepository<Item> _items;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<JournalEntryStub> _journal;
private readonly IFifoCostingService _fifo; private readonly IFifoCostingService _fifo;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly IGlAccountConfigService _glAccounts; private readonly IGlAccountConfigService _glAccounts;
@@ -50,15 +47,10 @@ public sealed class OpeningStockService : IOpeningStockService
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public OpeningStockService( public OpeningStockService(
IRepository<OpeningBalanceRun> runs, IRepository<Item> items, IRepository<Warehouse> warehouses, IOpeningStockRepository runs, IFifoCostingService fifo,
IRepository<StockLedger> ledger, IRepository<JournalEntryStub> journal, IFifoCostingService fifo,
IGeneralLedgerService gl, IGlAccountConfigService glAccounts, ICurrentUser currentUser, IUnitOfWork uow) IGeneralLedgerService gl, IGlAccountConfigService glAccounts, ICurrentUser currentUser, IUnitOfWork uow)
{ {
_runs = runs; _runs = runs;
_items = items;
_warehouses = warehouses;
_ledger = ledger;
_journal = journal;
_fifo = fifo; _fifo = fifo;
_gl = gl; _gl = gl;
_glAccounts = glAccounts; _glAccounts = glAccounts;
@@ -68,25 +60,15 @@ public sealed class OpeningStockService : IOpeningStockService
public async Task<IReadOnlyList<OpeningStockRunSummaryDto>> ListAsync(CancellationToken ct = default) public async Task<IReadOnlyList<OpeningStockRunSummaryDto>> ListAsync(CancellationToken ct = default)
{ {
var rows = await _runs.Query().AsNoTracking() var rows = await _runs.ListStockRunsAsync(ct);
.Where(r => r.Kind == OpeningBalanceKind.Stock) return rows.Select(x => new OpeningStockRunSummaryDto(
.OrderByDescending(r => r.OpeningBalanceRunId) x.Run.OpeningBalanceRunId, x.Run.CutoverDate, x.Run.GlJournalNo, x.Run.TotalAmount, x.LineCount,
.Select(r => new x.Run.PostedAt, x.Run.PostedBy)).ToList();
{
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();
} }
public async Task<OpeningStockRunDto?> GetAsync(int runId, CancellationToken ct = default) public async Task<OpeningStockRunDto?> GetAsync(int runId, CancellationToken ct = default)
{ {
var run = await _runs.Query().AsNoTracking() var run = await _runs.GetStockRunAsync(runId, ct);
.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);
return run is null ? null : Map(run); return run is null ? null : Map(run);
} }
@@ -99,8 +81,8 @@ public sealed class OpeningStockService : IOpeningStockService
// ---- validate every line before anything is written // ---- validate every line before anything is written
var itemIds = request.Lines.Select(l => l.ItemId).Distinct().ToList(); var itemIds = request.Lines.Select(l => l.ItemId).Distinct().ToList();
var warehouseIds = request.Lines.Select(l => l.WarehouseId).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 items = await _runs.GetItemsAsync(itemIds, ct);
var warehouses = await _warehouses.Query().AsNoTracking().Where(w => warehouseIds.Contains(w.WarehouseId)).ToDictionaryAsync(w => w.WarehouseId, ct); var warehouses = await _runs.GetWarehousesAsync(warehouseIds, ct);
var seen = new HashSet<(int, int)>(); var seen = new HashSet<(int, int)>();
foreach (var line in request.Lines) 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 // ---- an item/warehouse that already has movements is not "opening" any more
var existing = await _ledger.Query().AsNoTracking() var existing = await _runs.ListMovedPairsAsync(itemIds, warehouseIds, ct);
.Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId))
.Select(l => new { l.ItemId, l.WarehouseId })
.Distinct()
.ToListAsync(ct);
var clashes = existing.Where(e => seen.Contains((e.ItemId, e.WarehouseId))).ToList(); var clashes = existing.Where(e => seen.Contains((e.ItemId, e.WarehouseId))).ToList();
if (clashes.Count > 0) if (clashes.Count > 0)
{ {
@@ -169,7 +147,7 @@ public sealed class OpeningStockService : IOpeningStockService
total += value; total += value;
await _fifo.CreateInboundLayerAsync(line.ItemId, line.WarehouseId, null, null, null, line.Qty, line.UnitCost, receiptDate, token); 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, ItemId = line.ItemId,
WarehouseId = line.WarehouseId, WarehouseId = line.WarehouseId,
@@ -183,7 +161,7 @@ public sealed class OpeningStockService : IOpeningStockService
SourceDocId = entity.OpeningBalanceRunId, SourceDocId = entity.OpeningBalanceRunId,
CreatedAt = receiptDate CreatedAt = receiptDate
}, token); }, token);
await _journal.AddAsync(new JournalEntryStub await _runs.AddJournalStubAsync(new JournalEntryStub
{ {
SourceDocType = SourceDocType, SourceDocType = SourceDocType,
SourceDocId = entity.OpeningBalanceRunId, SourceDocId = entity.OpeningBalanceRunId,
@@ -8,7 +8,7 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IPriceAdjustmentRepository
namespace ERPCore.Services; 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="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 /// <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. /// 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> /// </summary>
public sealed class PriceAdjustmentService : IPriceAdjustmentService public sealed class PriceAdjustmentService : IPriceAdjustmentService
{ {
public const string ApprovalDocType = "PADJ"; public const string ApprovalDocType = "PADJ";
public const string ApprovalNavCode = "products.price-adjustments"; public const string ApprovalNavCode = "products.price-adjustments";
private readonly IRepository<PriceAdjustment> _adjustments; private readonly IPriceAdjustmentRepository _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 IApprovalService _approvals; private readonly IApprovalService _approvals;
private readonly INotificationService _notifications; private readonly INotificationService _notifications;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
@@ -37,17 +34,10 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public PriceAdjustmentService( public PriceAdjustmentService(
IRepository<PriceAdjustment> adjustments, IRepository<PriceAdjustmentLine> lines, IRepository<Item> items, IPriceAdjustmentRepository adjustments, IApprovalService approvals, INotificationService notifications,
IRepository<ItemPriceHistory> history, IRepository<User> users, IRepository<ApprovalConfig> config, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
IApprovalService approvals, INotificationService notifications, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
{ {
_adjustments = adjustments; _adjustments = adjustments;
_lines = lines;
_items = items;
_history = history;
_users = users;
_config = config;
_approvals = approvals; _approvals = approvals;
_notifications = notifications; _notifications = notifications;
_numbers = numbers; _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) public async Task<PagedResponse<PriceAdjustmentSummaryDto>> ListAsync(PageQuery query, PriceAdjustmentStatus? status, int? itemId, CancellationToken ct = default)
{ {
var q = _adjustments.Query().AsNoTracking(); var (rows, total) = await _adjustments.ListPagedAsync(query.Q, status, itemId, query.Skip, query.PageSize, ct);
if (!string.IsNullOrWhiteSpace(query.Q)) var items = rows.Select(p => new PriceAdjustmentSummaryDto(
{ p.PriceAdjustmentId, p.DocNo, p.Reason, p.Status, p.RequiredApprovals, PendingLevel(p.Status),
var term = query.Q.Trim(); p.LineCount, p.CreatedBy, p.CreatorName, p.CreatedAt, p.AppliedAt)).ToList();
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || EF.Functions.Like(p.Reason, $"%{term}%")); return PagedResponse<PriceAdjustmentSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
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);
} }
public async Task<PriceAdjustmentDto?> GetAsync(int priceAdjustmentId, CancellationToken ct = default) 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); 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); 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 ids = request.Lines.Select(l => l.ItemId).ToList();
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct); var items = await _adjustments.GetItemsForUpdateAsync(ids, ct);
// Queried from the line side (not PriceAdjustment.Lines via SelectMany) so this stays a var pendingItemIds = await _adjustments.ListPendingRequestsForItemsAsync(ids, ct);
// 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 lines = new List<PriceAdjustmentLine>(); var lines = new List<PriceAdjustmentLine>();
var largestChange = 0m; var largestChange = 0m;
@@ -112,7 +81,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
if (!items.TryGetValue(input.ItemId, out var item)) if (!items.TryGetValue(input.ItemId, out var item))
throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422); throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
var clash = pendingItemIds.FirstOrDefault(x => x.ItemId == input.ItemId); 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, throw new DomainException(ErrorCodes.PriceAdjustmentItemPending,
$"{item.Sku} already has a pending price change ({clash.DocNo}) — approve, reject or withdraw that first.", 409); $"{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); ValidatePrices(item.Sku, input.NewMinPrice, input.NewMaxPrice, input.NewFlatPrice);
@@ -184,8 +153,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
adjustment.Status = PriceAdjustmentStatus.PendingSecondApproval; adjustment.Status = PriceAdjustmentStatus.PendingSecondApproval;
return 0; return 0;
} }
var ids = adjustment.Lines.Select(l => l.ItemId).ToList(); var items = await _adjustments.GetItemsForUpdateAsync(adjustment.Lines.Select(l => l.ItemId).ToList(), token);
var items = await _items.Query().Where(i => ids.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, token);
adjustment.Status = PriceAdjustmentStatus.Approved; adjustment.Status = PriceAdjustmentStatus.Approved;
await ApplyInCurrentTransactionAsync(adjustment, items, now, token); await ApplyInCurrentTransactionAsync(adjustment, items, now, token);
return 0; return 0;
@@ -211,11 +179,13 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
var adjustment = await LoadPendingAsync(priceAdjustmentId, ct); var adjustment = await LoadPendingAsync(priceAdjustmentId, ct);
await _approvals.DecideAsync(Target(adjustment), approve: false, reason, ct); await _approvals.DecideAsync(Target(adjustment), approve: false, reason, ct);
await SaveDecisionAsync(adjustment, () =>
{
adjustment.Status = PriceAdjustmentStatus.Rejected; adjustment.Status = PriceAdjustmentStatus.Rejected;
adjustment.RejectedBy = _currentUser.AuditUserId; adjustment.RejectedBy = _currentUser.AuditUserId;
adjustment.RejectedAt = DateTime.UtcNow; adjustment.RejectedAt = DateTime.UtcNow;
adjustment.RejectReason = reason.Trim(); adjustment.RejectReason = reason.Trim();
await SaveDecisionAsync(adjustment, ct); }, ct);
await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.rejected", await _notifications.NotifyUsersAsync([adjustment.CreatedBy], "approval.rejected",
$"Rejected: {adjustment.DocNo}", $"Price adjustment {adjustment.DocNo} was rejected: {adjustment.RejectReason}", $"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) if (adjustment.CreatedBy != _currentUser.AuditUserId)
throw new DomainException(ErrorCodes.ApprovalForbidden, "Only the person who made the request can withdraw it.", 403); throw new DomainException(ErrorCodes.ApprovalForbidden, "Only the person who made the request can withdraw it.", 403);
await SaveDecisionAsync(adjustment, () =>
{
adjustment.Status = PriceAdjustmentStatus.Cancelled; adjustment.Status = PriceAdjustmentStatus.Cancelled;
adjustment.CancelledAt = DateTime.UtcNow; adjustment.CancelledAt = DateTime.UtcNow;
await SaveDecisionAsync(adjustment, ct); }, ct);
return (await GetAsync(adjustment.PriceAdjustmentId, ct))!; return (await GetAsync(adjustment.PriceAdjustmentId, ct))!;
} }
public async Task<IReadOnlyList<ItemPriceHistoryDto>> ItemHistoryAsync(int itemId, CancellationToken ct = default) public async Task<IReadOnlyList<ItemPriceHistoryDto>> ItemHistoryAsync(int itemId, CancellationToken ct = default)
{ {
var rows = await _history.Query().AsNoTracking() var rows = await _adjustments.ListItemHistoryAsync(itemId, ct);
.Where(h => h.ItemId == itemId)
.OrderByDescending(h => h.AppliedAt).ThenByDescending(h => h.ItemPriceHistoryId)
.ToListAsync(ct);
var userIds = rows.SelectMany(r => new int?[] { r.RequestedBy, r.ApprovedLevel1By, r.ApprovedLevel2By }).OfType<int>().Distinct().ToList(); 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 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; string? Name(int? id) => id is int v ? names.GetValueOrDefault(v) : null;
return rows.Select(h => new ItemPriceHistoryDto( return rows.Select(h => new ItemPriceHistoryDto(
@@ -256,8 +225,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
} }
public async Task<bool> PricesNeedApprovalAsync(CancellationToken ct = default) public async Task<bool> PricesNeedApprovalAsync(CancellationToken ct = default)
=> (await _config.Query().AsNoTracking().Where(c => c.ApprovalConfigId == ApprovalConfig.SingletonId) => (await _adjustments.GetPriceAdjustmentLevelsAsync(ct) ?? 2) > 0;
.Select(c => (int?)c.PriceAdjustmentLevels).FirstOrDefaultAsync(ct) ?? 2) > 0;
// --- Helpers --------------------------------------------------------------------------------- // --- Helpers ---------------------------------------------------------------------------------
@@ -277,7 +245,7 @@ public sealed class PriceAdjustmentService : IPriceAdjustmentService
item.SellingFlatPrice = line.NewFlatPrice; item.SellingFlatPrice = line.NewFlatPrice;
item.UpdatedAt = now; item.UpdatedAt = now;
await _history.AddAsync(new ItemPriceHistory await _adjustments.AddHistoryAsync(new ItemPriceHistory
{ {
ItemId = item.ItemId, ItemId = item.ItemId,
PriceAdjustment = adjustment, // FK set by EF, also when the request is new 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; 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) private async Task<PriceAdjustment> LoadPendingAsync(int priceAdjustmentId, CancellationToken ct)
{ {
var adjustment = await _adjustments.Query().Include(p => p.Lines) var adjustment = await _adjustments.GetForDecisionAsync(priceAdjustmentId, ct)
.FirstOrDefaultAsync(p => p.PriceAdjustmentId == priceAdjustmentId, ct)
?? throw new NotFoundException($"Price adjustment {priceAdjustmentId} was not found."); ?? throw new NotFoundException($"Price adjustment {priceAdjustmentId} was not found.");
if (adjustment.Status is not (PriceAdjustmentStatus.PendingApproval or PriceAdjustmentStatus.PendingSecondApproval)) 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); throw new DomainException(ErrorCodes.ApprovalNotPending, $"Price adjustment {adjustment.DocNo} is {adjustment.Status} and not waiting for approval.", 409);
return adjustment; 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 try
{ {
await _uow.SaveChangesAsync(ct); await _uow.ExecuteInTransactionAsync(_ =>
{
decide();
return Task.CompletedTask;
}, ct);
} }
catch (DbUpdateConcurrencyException) 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.PendingApproval => 1,
PriceAdjustmentStatus.PendingSecondApproval => 2, PriceAdjustmentStatus.PendingSecondApproval => 2,
@@ -8,18 +8,22 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IProcurementConfigRepository
namespace ERPCore.Services; 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 public sealed class ProcurementConfigService : IProcurementConfigService
{ {
private readonly IRepository<ProcurementConfig> _config; private readonly IProcurementConfigRepository _config;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public ProcurementConfigService(IRepository<ProcurementConfig> config, ICurrentUser currentUser, IUnitOfWork uow) public ProcurementConfigService(IProcurementConfigRepository config, ICurrentUser currentUser, IUnitOfWork uow)
{ {
_config = config; _config = config;
_currentUser = currentUser; _currentUser = currentUser;
@@ -28,8 +32,7 @@ public sealed class ProcurementConfigService : IProcurementConfigService
public async Task<ETagged<ProcurementConfigDto>> GetAsync(CancellationToken ct = default) public async Task<ETagged<ProcurementConfigDto>> GetAsync(CancellationToken ct = default)
{ {
var config = await _config.Query().AsNoTracking() var config = await _config.GetForReadAsync(ct)
.FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct)
?? throw new NotFoundException("Procurement configuration has not been seeded."); ?? throw new NotFoundException("Procurement configuration has not been seeded.");
return new ETagged<ProcurementConfigDto>(Map(config), config.RowVersion); return new ETagged<ProcurementConfigDto>(Map(config), config.RowVersion);
@@ -44,6 +47,10 @@ public sealed class ProcurementConfigService : IProcurementConfigService
if (config.RowVersion != expectedRowVersion) if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
try
{
await _uow.ExecuteInTransactionAsync(_ =>
{
config.VendorContactMode = request.VendorContactMode!.Value; config.VendorContactMode = request.VendorContactMode!.Value;
config.VendorAddressEnabled = request.VendorAddressEnabled!.Value; config.VendorAddressEnabled = request.VendorAddressEnabled!.Value;
config.PoTaxEnabled = request.PoTaxEnabled!.Value; config.PoTaxEnabled = request.PoTaxEnabled!.Value;
@@ -51,10 +58,8 @@ public sealed class ProcurementConfigService : IProcurementConfigService
config.VendorTaxRegEnabled = request.VendorTaxRegEnabled!.Value; config.VendorTaxRegEnabled = request.VendorTaxRegEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow; config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId; config.UpdatedBy = _currentUser.AuditUserId;
return Task.CompletedTask;
try }, ct);
{
await _uow.SaveChangesAsync(ct);
} }
catch (DbUpdateConcurrencyException) catch (DbUpdateConcurrencyException)
{ {
@@ -66,8 +71,7 @@ public sealed class ProcurementConfigService : IProcurementConfigService
public async Task<ProcurementConfigDto> GetFlagsAsync(CancellationToken ct = default) public async Task<ProcurementConfigDto> GetFlagsAsync(CancellationToken ct = default)
{ {
var config = await _config.Query().AsNoTracking() var config = await _config.GetForReadAsync(ct);
.FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct);
// No row (not seeded yet): contact/address/tax off, the long-standing terms and tax-reg fields on. // 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); 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.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IProductConfigRepository
namespace ERPCore.Services; namespace ERPCore.Services;
/// <summary> /// <summary>
/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder. /// 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 /// 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> /// </summary>
public sealed class ProductConfigService : IProductConfigService public sealed class ProductConfigService : IProductConfigService
{ {
private readonly IRepository<ProductConfig> _config; private readonly IProductConfigRepository _config;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public ProductConfigService(IRepository<ProductConfig> config, ICurrentUser currentUser, IUnitOfWork uow) public ProductConfigService(IProductConfigRepository config, ICurrentUser currentUser, IUnitOfWork uow)
{ {
_config = config; _config = config;
_currentUser = currentUser; _currentUser = currentUser;
@@ -30,8 +32,7 @@ public sealed class ProductConfigService : IProductConfigService
public async Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default) public async Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default)
{ {
var config = await _config.Query().AsNoTracking() var config = await _config.GetForReadAsync(ct)
.FirstOrDefaultAsync(c => c.ConfigId == ProductConfig.SingletonId, ct)
?? throw new NotFoundException("Product configuration has not been seeded."); ?? throw new NotFoundException("Product configuration has not been seeded.");
return new ETagged<ProductConfigDto>(Map(config), config.RowVersion); return new ETagged<ProductConfigDto>(Map(config), config.RowVersion);
@@ -46,6 +47,10 @@ public sealed class ProductConfigService : IProductConfigService
if (config.RowVersion != expectedRowVersion) if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
try
{
await _uow.ExecuteInTransactionAsync(_ =>
{
// Non-null by [Required] on the nullable bools - an omitted flag is a 400, so the // 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. // model binder has already rejected anything that would reach here with a null.
config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value; config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value;
@@ -70,10 +75,8 @@ public sealed class ProductConfigService : IProductConfigService
config.AlertWarehouseEnabled = config.LowStockAlertsEnabled && request.AlertWarehouseEnabled!.Value; config.AlertWarehouseEnabled = config.LowStockAlertsEnabled && request.AlertWarehouseEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow; config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId; config.UpdatedBy = _currentUser.AuditUserId;
return Task.CompletedTask;
try }, ct);
{
await _uow.SaveChangesAsync(ct);
} }
catch (DbUpdateConcurrencyException) catch (DbUpdateConcurrencyException)
{ {
@@ -8,7 +8,6 @@ using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using ERPCore.System.Time; using ERPCore.System.Time;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -19,22 +18,22 @@ namespace ERPCore.Services;
/// account) — the mirror image of <see cref="SalesInvoicePaymentService"/> (that one /// account) — the mirror image of <see cref="SalesInvoicePaymentService"/> (that one
/// credits Accounts Receivable when the customer pays; this one debits Accounts Payable /// 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 /// 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> /// </summary>
public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentService public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentService
{ {
private readonly IRepository<PurchaseInvoice> _invoices; private readonly IPurchaseInvoicePaymentRepository _payments;
private readonly IRepository<PurchaseInvoicePayment> _payments;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGlAccountConfigService _glAccounts; private readonly IGlAccountConfigService _glAccounts;
public PurchaseInvoicePaymentService( public PurchaseInvoicePaymentService(
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl, IPurchaseInvoicePaymentRepository payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts) ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
{ {
_invoices = invoices;
_payments = payments; _payments = payments;
_gl = gl; _gl = gl;
_currentUser = currentUser; _currentUser = currentUser;
@@ -44,7 +43,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default) 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."); ?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Posted) 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) 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."); throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
return await _payments.Query().AsNoTracking() return (await _payments.ListForInvoiceAsync(purchaseInvoiceId, ct)).Select(Map).ToList();
.Where(p => p.PurchaseInvoiceId == purchaseInvoiceId)
.OrderByDescending(p => p.PurchaseInvoicePaymentId)
.Select(p => new PurchaseInvoicePaymentDto(
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
.ToListAsync(ct);
} }
private static PurchaseInvoicePaymentDto Map(PurchaseInvoicePayment p) => new( private static PurchaseInvoicePaymentDto Map(PurchaseInvoicePayment p) => new(
@@ -12,7 +12,7 @@ using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using ERPCore.System.Time; using ERPCore.System.Time;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IPurchaseInvoiceRepository
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -24,13 +24,13 @@ namespace ERPCore.Services;
/// re-selected); Post reclassifies the GRN clearing balance to Accounts Payable via a /// re-selected); Post reclassifies the GRN clearing balance to Accounts Payable via a
/// real GL journal entry (mirrors <see cref="SalesInvoiceService"/>'s AR recognition); /// real GL journal entry (mirrors <see cref="SalesInvoiceService"/>'s AR recognition);
/// vendor payments are then recorded against this aggregate (see /// 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> /// </summary>
public sealed class PurchaseInvoiceService : IPurchaseInvoiceService public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
{ {
private readonly IRepository<PurchaseInvoice> _invoices; private readonly IPurchaseInvoiceRepository _invoices;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Grn> _grns;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
@@ -38,13 +38,10 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
private readonly IGlAccountConfigService _glAccounts; private readonly IGlAccountConfigService _glAccounts;
public PurchaseInvoiceService( public PurchaseInvoiceService(
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns, IPurchaseInvoiceRepository invoices, ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
IGeneralLedgerService gl, IGlAccountConfigService glAccounts) IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
_invoices = invoices; _invoices = invoices;
_vendors = vendors;
_grns = grns;
_currentUser = currentUser; _currentUser = currentUser;
_numbers = numbers; _numbers = numbers;
_uow = uow; _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) 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); var (rows, total) = await _invoices.ListPagedAsync(query.Q, status, vendorId, query.Skip, query.PageSize, ct);
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);
return PagedResponse<PurchaseInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); return PagedResponse<PurchaseInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
} }
public async Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default) public async Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default)
{ {
var invoice = await _invoices.Query().AsNoTracking() var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: false, ct);
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct);
return invoice is null ? null : new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion); return invoice is null ? null : new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
} }
public async Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default) 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); ?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var vendorInvoiceNo = request.VendorInvoiceNo.Trim(); 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 grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: null, ct);
var branchId = grns[0].BranchId; var branchId = grns[0].BranchId;
var invoice = await _uow.ExecuteInTransactionAsync(async token => var invoice = await _uow.ExecuteInTransactionAsync(async token =>
{ {
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: null, token);
var entity = new PurchaseInvoice var entity = new PurchaseInvoice
{ {
DocNo = await _numbers.NextAsync(DocumentTypes.PurchaseInvoice, branchId, token), 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) public async Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
{ {
var invoice = await _invoices.Query() var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: true, ct)
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found."); ?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.RowVersion != expectedRowVersion) if (invoice.RowVersion != expectedRowVersion)
@@ -130,14 +110,18 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
if (invoice.Status != PurchaseInvoiceStatus.Draft) if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new DomainException(ErrorCodes.PurchaseInvoiceNotEditable, $"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be edited.", 409); 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); ?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var vendorInvoiceNo = request.VendorInvoiceNo.Trim(); var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: purchaseInvoiceId, ct); var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
try
{
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. // Free GRNs that were linked to this invoice but are no longer selected.
var newGrnIds = grns.Select(g => g.GrnId).ToHashSet(); var newGrnIds = grns.Select(g => g.GrnId).ToHashSet();
foreach (var line in invoice.Lines) foreach (var line in invoice.Lines)
@@ -154,10 +138,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
foreach (var grn in grns) grn.PurchaseInvoice = invoice; foreach (var grn in grns) grn.PurchaseInvoice = invoice;
Recalculate(invoice); Recalculate(invoice);
invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedAt = DateTime.UtcNow;
}, ct);
try
{
await _uow.SaveChangesAsync(ct);
} }
catch (DbUpdateConcurrencyException) catch (DbUpdateConcurrencyException)
{ {
@@ -169,9 +150,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
public async Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default) public async Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default)
{ {
var invoice = await _invoices.Query() var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: true, ct)
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found."); ?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Draft) if (invoice.Status != PurchaseInvoiceStatus.Draft)
@@ -212,9 +191,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
public async Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default) public async Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default)
{ {
var invoice = await _invoices.Query() var invoice = await _invoices.GetWithLinesAsync(purchaseInvoiceId, tracking: true, ct)
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found."); ?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Draft) if (invoice.Status != PurchaseInvoiceStatus.Draft)
@@ -222,6 +199,8 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
var dto = MapInvoice(invoice); var dto = MapInvoice(invoice);
await _uow.ExecuteInTransactionAsync(_ =>
{
// Cancelling frees the consolidated GRNs so they can be picked up by a later 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), // 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. // while the Cancelled header row itself remains for audit history.
@@ -230,39 +209,31 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
invoice.Lines.Clear(); invoice.Lines.Clear();
invoice.Status = PurchaseInvoiceStatus.Cancelled; invoice.Status = PurchaseInvoiceStatus.Cancelled;
invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
return dto with { Status = PurchaseInvoiceStatus.Cancelled, UpdatedAt = invoice.UpdatedAt }; return dto with { Status = PurchaseInvoiceStatus.Cancelled, UpdatedAt = invoice.UpdatedAt };
} }
public async Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default) public async Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default)
{ {
return await _grns.Query().AsNoTracking() return (await _invoices.ListEligibleGrnsAsync(vendorId, ct))
.Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null)
.OrderBy(g => g.PostedAt)
.Select(g => new GrnEligibleForInvoiceDto( .Select(g => new GrnEligibleForInvoiceDto(
g.GrnId, g.DocNo, g.PostedAt ?? g.CreatedAt, g.WarehouseId, g.Lines.Count, g.GrnId, g.DocNo, g.ReceivedAt, g.WarehouseId, g.LineCount, g.Subtotal, g.VatTotal, g.GrandTotal))
g.Lines.Sum(l => l.ReceivedValue), g.Lines.Sum(l => l.VatAmount), g.Lines.Sum(l => l.LineTotal))) .ToList();
.ToListAsync(ct);
} }
private async Task EnsureVendorInvoiceNoIsUniqueAsync(int vendorId, string vendorInvoiceNo, int? excludingPurchaseInvoiceId, CancellationToken ct) private async Task EnsureVendorInvoiceNoIsUniqueAsync(int vendorId, string vendorInvoiceNo, int? excludingPurchaseInvoiceId, CancellationToken ct)
{ {
var duplicate = await _invoices.Query().AsNoTracking().AnyAsync(x => if (await _invoices.VendorInvoiceNoExistsAsync(vendorId, vendorInvoiceNo, excludingPurchaseInvoiceId, ct))
x.VendorId == vendorId
&& x.Status != PurchaseInvoiceStatus.Cancelled
&& x.VendorInvoiceNo == vendorInvoiceNo
&& (excludingPurchaseInvoiceId == null || x.PurchaseInvoiceId != excludingPurchaseInvoiceId), ct);
if (duplicate)
throw new DomainException(ErrorCodes.DuplicateVendorInvoiceNo, throw new DomainException(ErrorCodes.DuplicateVendorInvoiceNo,
$"Vendor invoice number '{vendorInvoiceNo}' already exists for this vendor.", 409); $"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 distinctIds = grnIds.Distinct().ToList();
var grns = await _grns.Query().Include(g => g.Lines) var grns = await _invoices.ListGrnsForInvoicingAsync(distinctIds, ct);
.Where(g => distinctIds.Contains(g.GrnId)).ToListAsync(ct);
if (grns.Count != distinctIds.Count) if (grns.Count != distinctIds.Count)
throw new DomainException(ErrorCodes.Validation, "One or more selected GRNs do not exist.", 422); throw new DomainException(ErrorCodes.Validation, "One or more selected GRNs do not exist.", 422);
+60 -124
View File
@@ -10,7 +10,7 @@ using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using ERPCore.System.Time; using ERPCore.System.Time;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; // DbUpdateConcurrencyException only — every query lives in IPurchaseOrderRepository
namespace ERPCore.Services; 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 /// 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 /// 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 /// (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> /// </summary>
public sealed class PurchaseOrderService : IPurchaseOrderService public sealed class PurchaseOrderService : IPurchaseOrderService
{ {
private const string BaseCurrency = "LKR"; private const string BaseCurrency = "LKR";
private readonly IRepository<PurchaseOrder> _pos; private readonly IPurchaseOrderRepository _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 INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly INotificationService _notifications; private readonly INotificationService _notifications;
@@ -38,22 +34,15 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public PurchaseOrderService( public PurchaseOrderService(
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions, IPurchaseOrderRepository pos, INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
IBranchConfigService branchConfig, IProcurementConfigService procurementConfig, IUnitOfWork uow) IBranchConfigService branchConfig, IProcurementConfigService procurementConfig, IUnitOfWork uow)
{ {
_procurementConfig = procurementConfig;
_pos = pos; _pos = pos;
_vendors = vendors;
_requisitions = requisitions;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
_notifications = notifications; _notifications = notifications;
_branchConfig = branchConfig; _branchConfig = branchConfig;
_procurementConfig = procurementConfig;
_uow = uow; _uow = uow;
} }
@@ -61,40 +50,16 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
PageQuery query, PurchaseOrderStatus? status, int? vendorId, DateOnly? from = null, DateOnly? to = null, PageQuery query, PurchaseOrderStatus? status, int? vendorId, DateOnly? from = null, DateOnly? to = null,
CancellationToken ct = default) 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`). // Created date, as a business (Colombo) day: [start of `from`, start of the day after `to`).
if (from is DateOnly fromDay) var criteria = new PurchaseOrderListCriteria(
{ query.Q, status, vendorId,
var start = BusinessClock.StartOfDayUtc(fromDay); from is DateOnly fromDay ? BusinessClock.StartOfDayUtc(fromDay) : null,
q = q.Where(p => p.CreatedAt >= start); to is DateOnly toDay ? BusinessClock.StartOfDayUtc(toDay.AddDays(1)) : null,
} query.Sort);
if (to is DateOnly toDay) var (rows, total) = await _pos.ListPagedAsync(criteria, query.Skip, query.PageSize, ct);
{
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 flags = await _procurementConfig.GetFlagsAsync(ct); var flags = await _procurementConfig.GetFlagsAsync(ct);
var vendorIds = rows.Select(p => p.VendorId).Distinct().ToList(); var vendorMap = await _pos.GetVendorsAsync(rows.Select(p => p.VendorId).Distinct().ToList(), ct);
var vendorMap = await _vendors.Query().AsNoTracking()
.Where(v => vendorIds.Contains(v.VendorId))
.ToDictionaryAsync(v => v.VendorId, ct);
var summaries = rows.Select(p => new PurchaseOrderSummaryDto( 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), 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); 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) public async Task<ETagged<PurchaseOrderDto>?> GetAsync(int poId, CancellationToken ct = default)
{ {
var po = await _pos.Query().AsNoTracking() var po = await _pos.GetWithLinesAsync(poId, tracking: false, ct);
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct);
if (po is null) return null; if (po is null) return null;
return new ETagged<PurchaseOrderDto>(await ToDtoAsync(po, ct), po.RowVersion); 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( public async Task<ETagged<PurchaseOrderDto>> UpdateAsync(
int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default) int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default)
{ {
var po = await _pos.Query() var po = await LoadForUpdateAsync(poId, ct);
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.RowVersion != expectedRowVersion) if (po.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412); throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412);
@@ -189,6 +123,10 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct); await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
await EnsureLinesInBranchAsync(po.BranchId, request.Lines.Select(l => l.WarehouseId), ct); await EnsureLinesInBranchAsync(po.BranchId, request.Lines.Select(l => l.WarehouseId), ct);
try
{
await _uow.ExecuteInTransactionAsync(_ =>
{
po.VendorId = request.VendorId; po.VendorId = request.VendorId;
po.RequisitionId = request.RequisitionId; po.RequisitionId = request.RequisitionId;
po.UpdatedAt = DateTime.UtcNow; po.UpdatedAt = DateTime.UtcNow;
@@ -197,10 +135,8 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
po.Lines.Clear(); po.Lines.Clear();
foreach (var input in request.Lines) foreach (var input in request.Lines)
po.Lines.Add(ToLine(input)); po.Lines.Add(ToLine(input));
return Task.CompletedTask;
try }, ct);
{
await _uow.SaveChangesAsync(ct);
} }
catch (DbUpdateConcurrencyException) catch (DbUpdateConcurrencyException)
{ {
@@ -214,11 +150,14 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
{ {
var po = await LoadForApprovalDecisionAsync(poId, ct); var po = await LoadForApprovalDecisionAsync(poId, ct);
await _uow.ExecuteInTransactionAsync(_ =>
{
po.Status = PurchaseOrderStatus.Approved; po.Status = PurchaseOrderStatus.Approved;
po.ApprovedBy = _currentUser.AuditUserId; po.ApprovedBy = _currentUser.AuditUserId;
po.ApprovedAt = DateTime.UtcNow; po.ApprovedAt = DateTime.UtcNow;
po.UpdatedAt = po.ApprovedAt; po.UpdatedAt = po.ApprovedAt;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
await _notifications.RaiseAsync("purchase.order.approved", "Purchase Order Approved", $"Purchase order {po.DocNo} was approved.", nameof(PurchaseOrder), po.PoId, 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); return await ToDtoAsync(po, ct);
@@ -228,11 +167,14 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
{ {
var po = await LoadForApprovalDecisionAsync(poId, ct); var po = await LoadForApprovalDecisionAsync(poId, ct);
await _uow.ExecuteInTransactionAsync(_ =>
{
// Rejected, not Cancelled — a reject is "revise and resubmit", not "abandon" - and not Draft either, so the // 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. // list and the creator can tell it was turned down.
po.Status = PurchaseOrderStatus.Rejected; po.Status = PurchaseOrderStatus.Rejected;
po.UpdatedAt = DateTime.UtcNow; po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
var suffix = string.IsNullOrWhiteSpace(reason) ? "." : $": {reason}"; 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); 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> /// 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) private async Task<PurchaseOrder> LoadForApprovalDecisionAsync(int poId, CancellationToken ct)
{ {
var po = await _pos.Query() var po = await LoadForUpdateAsync(poId, ct);
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.Status != PurchaseOrderStatus.PendingApproval) if (po.Status != PurchaseOrderStatus.PendingApproval)
throw new DomainException(ErrorCodes.PoNotPendingApproval, $"Purchase order {poId} is {po.Status} and is not awaiting approval.", 409); 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) public async Task<PurchaseOrderDto> CancelAsync(int poId, string? reason, CancellationToken ct = default)
{ {
var po = await _pos.Query() var po = await LoadForUpdateAsync(poId, ct);
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.Lines.Any(l => l.QtyReceived > 0)) if (po.Lines.Any(l => l.QtyReceived > 0))
throw new ConflictException($"Purchase order {poId} cannot be cancelled because goods have been received against it."); throw new ConflictException($"Purchase order {poId} cannot be cancelled because goods have been received against it.");
if (po.Status != PurchaseOrderStatus.Cancelled) if (po.Status != PurchaseOrderStatus.Cancelled)
{
await _uow.ExecuteInTransactionAsync(_ =>
{ {
po.Status = PurchaseOrderStatus.Cancelled; po.Status = PurchaseOrderStatus.Cancelled;
po.UpdatedAt = DateTime.UtcNow; po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); return Task.CompletedTask;
}, ct);
} }
return await ToDtoAsync(po, 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) public async Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default)
{ {
var po = await _pos.Query() var po = await LoadForUpdateAsync(poId, ct);
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (!IsEditable(po.Status)) if (!IsEditable(po.Status))
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409); throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409);
await _uow.ExecuteInTransactionAsync(_ =>
{
po.Status = PurchaseOrderStatus.PendingApproval; po.Status = PurchaseOrderStatus.PendingApproval;
po.ApprovalRequired = true; po.ApprovalRequired = true;
po.UpdatedAt = DateTime.UtcNow; po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); 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); 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); return await ToDtoAsync(po, ct);
@@ -301,18 +240,22 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
public async Task DeleteAsync(int poId, CancellationToken ct = default) public async Task DeleteAsync(int poId, CancellationToken ct = default)
{ {
var po = await _pos.Query() var po = await LoadForUpdateAsync(poId, ct);
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (!IsEditable(po.Status)) 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); 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);
await _uow.ExecuteInTransactionAsync(_ =>
{
_pos.Remove(po); _pos.Remove(po);
await _uow.SaveChangesAsync(ct); 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. // 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. // 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; 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( private async Task ValidateReferencesAsync(
int vendorId, int? requisitionId, IReadOnlyCollection<CreatePoLineInput> lines, CancellationToken ct) int vendorId, int? requisitionId, IReadOnlyCollection<CreatePoLineInput> lines, CancellationToken ct)
{ {
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct); var vendor = await _pos.GetVendorAsync(vendorId, ct)
if (vendor is null) ?? throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422);
throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422);
if (vendor.Status != EntityStatus.Active) if (vendor.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} is inactive.", 422); throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} is inactive.", 422);
if (requisitionId is not null if (requisitionId is int reqId && !await _pos.RequisitionExistsAsync(reqId, ct))
&& !await _requisitions.Query().AnyAsync(r => r.RequisitionId == requisitionId, ct))
throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422); 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 // 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)) 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); 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); var itemIds = lines.Select(l => l.ItemId).Distinct().ToList();
await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct); 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 /// <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> /// 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) private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable<int> warehouseIds, CancellationToken ct)
{ {
var ids = warehouseIds.Distinct().ToList(); var mismatched = await _pos.ListWarehousesOutsideBranchAsync(warehouseIds.Distinct().ToList(), branchId, ct);
var mismatched = await _warehouses.Query().AsNoTracking()
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId)
.Select(w => w.WarehouseId)
.ToListAsync(ct);
if (mismatched.Count > 0) if (mismatched.Count > 0)
throw new DomainException(ErrorCodes.Validation, throw new DomainException(ErrorCodes.Validation,
$"Warehouse(s) {string.Join(", ", mismatched)} do not belong to this purchase order's branch.", 422); $"Warehouse(s) {string.Join(", ", mismatched)} do not belong to this purchase order's branch.", 422);
} }
private static async Task EnsureAllExistAsync( private static void EnsureAllExist(IReadOnlyCollection<int> requested, IReadOnlyCollection<int> found, string label)
IQueryable<int> keySource, IEnumerable<int> requested, string label, CancellationToken ct)
{ {
var ids = requested.Distinct().ToList(); var missing = requested.Except(found).ToList();
var found = await keySource.Where(k => ids.Contains(k)).ToListAsync(ct);
var missing = ids.Except(found).ToList();
if (missing.Count > 0) if (missing.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"{label}(s) not found: {string.Join(", ", missing)}.", 422); 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) 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); var flags = await _procurementConfig.GetFlagsAsync(ct);
return Map(p, vendor is null ? null : ToVendorDto(vendor, flags)); return Map(p, vendor is null ? null : ToVendorDto(vendor, flags));
} }
@@ -10,7 +10,6 @@ using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors; using ERPCore.System.Errors;
using ERPCore.System.Time; using ERPCore.System.Time;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -24,21 +23,15 @@ namespace ERPCore.Services;
/// (FIFO consume, row-locked; over-return beyond available → STOCK_NEGATIVE_BLOCKED). /// (FIFO consume, row-locked; over-return beyond available → STOCK_NEGATIVE_BLOCKED).
/// Single UoW transaction. Also posts a real GL journal entry reversing the /// Single UoW transaction. Also posts a real GL journal entry reversing the
/// Inventory/Clearing lines the original <see cref="Grn"/> posted — reuses /// 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> /// </summary>
public sealed class PurchaseReturnService : IPurchaseReturnService public sealed class PurchaseReturnService : IPurchaseReturnService
{ {
private readonly IRepository<PurchaseReturn> _returns; private readonly IPurchaseReturnRepository _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 IStockMutator _mutator; private readonly IStockMutator _mutator;
private readonly IFifoCostingService _fifo; private readonly IFifoCostingService _fifo;
private readonly IRepository<Serial> _serials;
private readonly ISerialTrackingService _serialTracking; private readonly ISerialTrackingService _serialTracking;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
@@ -48,23 +41,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly IGlAccountConfigService _glAccounts; private readonly IGlAccountConfigService _glAccounts;
public PurchaseReturnService( public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses, IPurchaseReturnRepository returns, IStockMutator mutator, IFifoCostingService fifo, ISerialTrackingService serialTracking,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
IRepository<Serial> serials, ISerialTrackingService serialTracking,
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications, INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
_serials = serials;
_serialTracking = serialTracking;
_returns = returns; _returns = returns;
_vendors = vendors; _serialTracking = serialTracking;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_grnLines = grnLines;
_layers = layers;
_ledger = ledger;
_mutator = mutator; _mutator = mutator;
_fifo = fifo; _fifo = fifo;
_numbers = numbers; _numbers = numbers;
@@ -78,42 +60,20 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync( public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default) PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default)
{ {
var q = _returns.Query().AsNoTracking(); var (rows, total) = await _returns.ListPagedAsync(query.Q, vendorId, warehouseId, query.Skip, query.PageSize, ct);
var items = rows.Select(x => new PurchaseReturnSummaryDto(
if (!string.IsNullOrWhiteSpace(query.Q)) 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();
var term = query.Q.Trim(); return PagedResponse<PurchaseReturnSummaryDto>.Create(items, query.Page, query.PageSize, total);
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);
} }
public async Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default) public async Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
{ {
var ret = await _returns.Query().AsNoTracking() var ret = await _returns.GetWithLinesAsync(returnId, ct);
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
if (ret is null) return null; if (ret is null) return null;
// Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup. // Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking() return ToDto(ret, await _returns.ListLedgerIdsAsync(returnId, ct));
.Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(ret, ledgerRefs);
} }
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default) public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
@@ -121,12 +81,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
if (request.ReasonCodeId is null) if (request.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for a purchase return.", 400); 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); 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); 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); ?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Return) if (reason.Context != ReasonContext.Return)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422); 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) 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); ?? 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 // 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). // 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 }) if (!isRejectedLine || line.SerialIds is { Count: > 0 })
serialsByLine[line] = await _serialTracking.ResolveOutboundAsync( serialsByLine[line] = await _serialTracking.ResolveOutboundAsync(
item, request.WarehouseId, line.Qty, line.SerialIds, documentSerials, item, request.WarehouseId, line.Qty, line.SerialIds, documentSerials,
@@ -155,15 +116,14 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
if (line.GrnLineId is not null) if (line.GrnLineId is not null)
{ {
var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct) if (grnLine is null)
?? throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} does not exist.", 422); throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} does not exist.", 422);
if (grnLine.ItemId != line.ItemId) if (grnLine.ItemId != line.ItemId)
throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} is for a different item.", 422); throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} is for a different item.", 422);
if (grnLine.HoldStatus == HoldStatus.Rejected) if (grnLine.HoldStatus == HoldStatus.Rejected)
{ {
var remaining = await _layers.Query().AsNoTracking() var remaining = await _returns.SumRemainingOnGrnLineAsync(grnLine.GrnLineId, ct);
.Where(l => l.GrnLineId == line.GrnLineId).SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (line.Qty > remaining) if (line.Qty > remaining)
throw new DomainException(ErrorCodes.ReturnQtyExceedsRejected, throw new DomainException(ErrorCodes.ReturnQtyExceedsRejected,
$"GRN line {line.GrnLineId} has {remaining} rejected unit(s) left to return, but {line.Qty} was requested.", 422); $"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>(); var balances = new Dictionary<int, decimal>();
foreach (var line in rejectedLines) foreach (var line in rejectedLines)
{ {
var layers = await _layers.Query() var layers = await _returns.ListOpenLayersForGrnLineAsync(line.GrnLineId!.Value, token);
.Where(l => l.GrnLineId == line.GrnLineId && l.QtyRemaining > 0)
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.ToListAsync(token);
// A serial line's layers are one per unit: named serials take exactly theirs; // 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). // 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)) if (line.Qty != layers.Sum(l => l.QtyRemaining))
throw new DomainException(ErrorCodes.TrackingRequired, throw new DomainException(ErrorCodes.TrackingRequired,
$"GRN line {line.GrnLineId} is serial-tracked; select which {line.Qty} serial(s) go back.", 422); $"GRN line {line.GrnLineId} is serial-tracked; select which {line.Qty} serial(s) go back.", 422);
named = await _serials.Query() named = await _returns.ListSerialsAsync(layers.Where(l => l.SerialId != null).Select(l => l.SerialId!.Value).ToList(), token);
.Where(s => layers.Select(l => l.SerialId).Contains(s.SerialId)).ToListAsync(token);
serialsByLine[line] = named; serialsByLine[line] = named;
} }
@@ -260,7 +216,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
// Serial numbers switched off: serial units went back oldest-first without being // 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. // named — still record them as returned so the register doesn't show them in stock.
if (named.Count == 0 && takenSerialIds.Count > 0) 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) foreach (var serials in serialsByLine.Values)
@@ -1,15 +1,17 @@
"use client" "use client"
import { Fragment } from "react" import { Fragment } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation" 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> = { const SEGMENT_LABELS: Record<string, string> = {
dashboard: "Dashboard", dashboard: "Dashboard",
products: "Items", products: "Items",
@@ -48,55 +50,81 @@ const SEGMENT_LABELS: Record<string, string> = {
"owner-report": "Day-End Owner Report", "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> = { const GROUP_LANDING: Record<string, string> = {
"/dashboard/production": "/dashboard/production/runs", "/dashboard/production": "/dashboard/production/runs",
"/dashboard/receiving": "/dashboard/receiving/grn", "/dashboard/receiving": "/dashboard/receiving/grn",
"/dashboard/sales/bundles": "/dashboard/sales/bundles/templates", "/dashboard/sales/bundles":
"/dashboard/sales/bundles/templates",
"/dashboard/help/tickets": "/dashboard/help", "/dashboard/help/tickets": "/dashboard/help",
} }
const linkClass = function labelFor(segment: string): string {
"truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm" 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() { export function Breadcrumbs() {
const pathname = usePathname() || "/dashboard" const pathname = usePathname() || "/dashboard"
const segments = pathname.split("/").filter(Boolean) const segments = pathname.split("/").filter(Boolean)
// Nothing to show above the dashboard root itself. if (segments.length <= 1) {
if (segments.length <= 1) return null return null
}
const crumbs = segments.map((segment, i) => { const crumbs = segments.map((segment, index) => {
const path = "/" + segments.slice(0, i + 1).join("/") const path = `/${segments
return { key: path, href: GROUP_LANDING[path] ?? path, label: labelFor(segment) } .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 ( return (
<Breadcrumb className="mb-4 print:hidden"> <Breadcrumb className="mb-4 print:hidden">
<BreadcrumbList> <BreadcrumbList>
{crumbs.map((crumb, i) => { {uniqueCrumbs.map((crumb, index) => {
const isLast = i === crumbs.length - 1 const isLast = index === uniqueCrumbs.length - 1
return ( return (
<Fragment key={crumb.key}> <Fragment key={crumb.key}>
{index > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem> <BreadcrumbItem>
{isLast ? ( {isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage> <BreadcrumbPage>
) : (
<Link href={crumb.href} className={linkClass}>
{crumb.label} {crumb.label}
</Link> </BreadcrumbPage>
) : (
<span className="truncate text-muted-foreground">
{crumb.label}
</span>
)} )}
</BreadcrumbItem> </BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}
</Fragment> </Fragment>
) )
})} })}