Add inventory management features including Sales, Stock Returns, and Suppliers

- Implement Sale and SaleItem models for tracking sales transactions.
- Create StockReturn model to manage stock adjustments for various reasons.
- Introduce Supplier model to represent suppliers of products.
- Develop InventoryRepository for CRUD operations on products, suppliers, sales, and stock returns.
- Implement InventoryService to handle business logic for inventory management.
- Update TenantDbContext to include new models and relationships.
- Enhance ProfitLossRepository to account for sales, purchases, and stock returns in profit and loss calculations.
- Modify Program.cs to register new inventory-related services.
This commit is contained in:
2026-10-07 15:43:20 +05:30
parent f96db48ec2
commit 2eb88b058e
16 changed files with 1678 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class InventoryRepository : IInventoryRepository
{
private readonly TenantDbContext _context;
public InventoryRepository(TenantDbContext context)
{
_context = context;
}
// --- Products ---
public async Task<List<Product>> GetProducts(Guid clientId, bool includeInactive = false)
{
var query = _context.Products.Where(p => p.FKClientId == clientId);
if (!includeInactive)
query = query.Where(p => p.IsActive);
return await query.OrderBy(p => p.Name).ToListAsync();
}
public Task<Product?> GetProductById(Guid productId)
=> _context.Products.FirstOrDefaultAsync(p => p.ProductId == productId);
public async Task<Product> CreateProduct(Product product)
{
product.ProductId = Guid.NewGuid();
product.CreatedAt = DateTime.UtcNow;
product.IsActive = true;
product.CostPrice = 0m;
product.QuantityOnHand = 0m;
_context.Products.Add(product);
return product;
}
public async Task<Product> UpdateProduct(Product product)
{
var existing = await _context.Products.FindAsync(product.ProductId)
?? throw new KeyNotFoundException("Product not found");
// Sku is system-generated at creation and never edited -- not copied here.
existing.Name = product.Name;
existing.UnitOfMeasure = product.UnitOfMeasure;
existing.SellingPrice = product.SellingPrice;
existing.IsActive = product.IsActive;
return existing;
}
public async Task<int> GetNextProductSequence(Guid clientId)
=> await _context.Products.CountAsync(p => p.FKClientId == clientId) + 1;
public Task<List<GoodsReceivedNoteItem>> GetGrnItemsForProduct(Guid productId)
=> _context.GoodsReceivedNoteItems
.Include(i => i.Grn)
.Where(i => i.FKProductId == productId)
.OrderByDescending(i => i.Grn!.ReceivedAt)
.ToListAsync();
public Task<List<SaleItem>> GetSaleItemsForProduct(Guid productId)
=> _context.SaleItems
.Include(i => i.Sale)
.Where(i => i.FKProductId == productId)
.OrderByDescending(i => i.Sale!.SoldAt)
.ToListAsync();
public Task<List<StockReturn>> GetStockReturnsForProduct(Guid productId)
=> _context.StockReturns
.Where(r => r.FKProductId == productId)
.OrderByDescending(r => r.ReturnedAt)
.ToListAsync();
// --- Suppliers ---
public async Task<List<Supplier>> GetSuppliers(Guid clientId, bool includeInactive = false)
{
var query = _context.Suppliers.Where(s => s.FKClientId == clientId);
if (!includeInactive)
query = query.Where(s => s.IsActive);
return await query.OrderBy(s => s.Name).ToListAsync();
}
public Task<Supplier?> GetSupplierById(Guid supplierId)
=> _context.Suppliers.FirstOrDefaultAsync(s => s.SupplierId == supplierId);
public async Task<Supplier> CreateSupplier(Supplier supplier)
{
supplier.SupplierId = Guid.NewGuid();
supplier.CreatedAt = DateTime.UtcNow;
supplier.IsActive = true;
_context.Suppliers.Add(supplier);
return supplier;
}
// --- GRN ---
public async Task<GoodsReceivedNote> CreateGrn(GoodsReceivedNote grn)
{
grn.GrnId = Guid.NewGuid();
grn.CreatedAt = DateTime.UtcNow;
foreach (var item in grn.Items)
item.GrnItemId = Guid.NewGuid();
_context.GoodsReceivedNotes.Add(grn);
return grn;
}
public Task<List<GoodsReceivedNote>> GetGrns(Guid clientId)
=> _context.GoodsReceivedNotes
.Include(g => g.Supplier)
.Include(g => g.Items).ThenInclude(i => i.Product)
.Where(g => g.FKClientId == clientId)
.OrderByDescending(g => g.ReceivedAt)
.ToListAsync();
public Task<GoodsReceivedNote?> GetGrnById(Guid grnId)
=> _context.GoodsReceivedNotes
.Include(g => g.Supplier)
.Include(g => g.Items).ThenInclude(i => i.Product)
.FirstOrDefaultAsync(g => g.GrnId == grnId);
public async Task<int> GetNextGrnSequence(Guid clientId)
=> await _context.GoodsReceivedNotes.CountAsync(g => g.FKClientId == clientId) + 1;
// --- Sales ---
public async Task<Sale> CreateSale(Sale sale)
{
sale.SaleId = Guid.NewGuid();
sale.CreatedAt = DateTime.UtcNow;
foreach (var item in sale.Items)
item.SaleItemId = Guid.NewGuid();
_context.Sales.Add(sale);
return sale;
}
public Task<List<Sale>> GetSales(Guid clientId)
=> _context.Sales
.Include(s => s.Items).ThenInclude(i => i.Product)
.Where(s => s.FKClientId == clientId)
.OrderByDescending(s => s.SoldAt)
.ToListAsync();
public Task<Sale?> GetSaleById(Guid saleId)
=> _context.Sales
.Include(s => s.Items).ThenInclude(i => i.Product)
.FirstOrDefaultAsync(s => s.SaleId == saleId);
public async Task<int> GetNextSaleSequence(Guid clientId)
=> await _context.Sales.CountAsync(s => s.FKClientId == clientId) + 1;
// --- Returns ---
public async Task<StockReturn> CreateStockReturn(StockReturn stockReturn)
{
stockReturn.StockReturnId = Guid.NewGuid();
stockReturn.CreatedAt = DateTime.UtcNow;
_context.StockReturns.Add(stockReturn);
return stockReturn;
}
public Task<List<StockReturn>> GetStockReturns(Guid clientId)
=> _context.StockReturns
.Include(r => r.Product)
.Include(r => r.Sale)
.Include(r => r.Grn)
.Where(r => r.FKClientId == clientId)
.OrderByDescending(r => r.ReturnedAt)
.ToListAsync();
public Task<StockReturn?> GetStockReturnById(Guid stockReturnId)
=> _context.StockReturns
.Include(r => r.Product)
.Include(r => r.Sale)
.Include(r => r.Grn)
.FirstOrDefaultAsync(r => r.StockReturnId == stockReturnId);
}
}
+59
View File
@@ -105,9 +105,68 @@ namespace Fitness_core_BE.Repos
})
.ToListAsync(ct);
// Inventory: a Sale's revenue, a GRN's cost, and a StockReturn's Amount (staff-
// entered -- see StockReturn's doc comment). Every return Category still posts the
// same way here (one Expense category, reducing profit) even though CustomerReturn
// is the one category that adds the stock back -- the P&L side stays uniform while
// the stock-direction side doesn't.
var productSales = await _context.Sales
.AsNoTracking()
.Where(sale =>
sale.FKClientId == clientId &&
sale.SoldAt >= fromUtc &&
sale.SoldAt < toUtc)
.Select(sale => new LedgerEntryDto
{
EntryType = "Income",
Category = "Product Sales",
Description = sale.SaleNumber,
Amount = sale.TotalAmount,
EntryDate = sale.SoldAt,
IsManual = false
})
.ToListAsync(ct);
var inventoryPurchases = await _context.GoodsReceivedNotes
.AsNoTracking()
.Where(grn =>
grn.FKClientId == clientId &&
grn.ReceivedAt >= fromUtc &&
grn.ReceivedAt < toUtc)
.Select(grn => new LedgerEntryDto
{
EntryType = "Expense",
Category = "Inventory Purchases",
Description = grn.GrnNumber,
Amount = grn.TotalAmount,
EntryDate = grn.ReceivedAt,
IsManual = false
})
.ToListAsync(ct);
var inventoryWriteOffs = await _context.StockReturns
.AsNoTracking()
.Where(stockReturn =>
stockReturn.FKClientId == clientId &&
stockReturn.ReturnedAt >= fromUtc &&
stockReturn.ReturnedAt < toUtc)
.Select(stockReturn => new LedgerEntryDto
{
EntryType = "Expense",
Category = "Inventory Write-offs",
Description = stockReturn.Category + " — " + stockReturn.Product!.Name,
Amount = stockReturn.Amount,
EntryDate = stockReturn.ReturnedAt,
IsManual = false
})
.ToListAsync(ct);
memberPayments.AddRange(platformInvoices);
memberPayments.AddRange(staffPayments);
memberPayments.AddRange(bookingPayments);
memberPayments.AddRange(productSales);
memberPayments.AddRange(inventoryPurchases);
memberPayments.AddRange(inventoryWriteOffs);
return memberPayments;
}