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
+55
View File
@@ -0,0 +1,55 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>Stock coming in, optionally from a Supplier -- some GRNs don't have one (e.g.
/// self-produced stock, or the supplier just isn't on file). Creating one increments each
/// line's Product.QuantityOnHand and overwrites Product.CostPrice with that line's UnitCost
/// (a simple latest-cost approach, not a weighted average).</summary>
public class GoodsReceivedNote
{
[Key]
public Guid GrnId { get; set; }
public Guid FKClientId { get; set; }
[ForeignKey(nameof(FKClientId))]
public Client Client { get; set; }
public Guid? FKSupplierId { get; set; }
[ForeignKey(nameof(FKSupplierId))]
public Supplier? Supplier { get; set; }
[MaxLength(32)]
public string GrnNumber { get; set; } = string.Empty;
public DateTime ReceivedAt { get; set; } = DateTime.UtcNow;
[MaxLength(300)]
public string? Notes { get; set; }
public decimal TotalAmount { get; set; } = 0m;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public virtual ICollection<GoodsReceivedNoteItem> Items { get; set; } = new List<GoodsReceivedNoteItem>();
}
public class GoodsReceivedNoteItem
{
[Key]
public Guid GrnItemId { get; set; }
public Guid FKGrnId { get; set; }
[ForeignKey(nameof(FKGrnId))]
public GoodsReceivedNote? Grn { get; set; }
public Guid FKProductId { get; set; }
[ForeignKey(nameof(FKProductId))]
public Product? Product { get; set; }
public decimal Quantity { get; set; }
public decimal UnitCost { get; set; }
public decimal LineTotal { get; set; }
}
}
+43
View File
@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>A stocked item sold at the gym (snacks, supplements, merchandise). QuantityOnHand
/// is a maintained running balance -- adjusted by GoodsReceivedNoteItem (+), SaleItem (-) and
/// StockReturn (-) -- not computed from those tables on every read.</summary>
public class Product
{
[Key]
public Guid ProductId { get; set; }
public Guid FKClientId { get; set; }
[ForeignKey(nameof(FKClientId))]
public Client Client { get; set; }
[MaxLength(128)]
public string Name { get; set; } = string.Empty;
[MaxLength(64)]
public string? Sku { get; set; }
/// <summary>Relative /Uploads path to the product's photo (same convention as
/// ClientBusiness.BusinessLogoId) -- null when no image has been uploaded.</summary>
[MaxLength(300)]
public string? ImageId { get; set; }
[MaxLength(20)]
public string UnitOfMeasure { get; set; } = "pcs";
/// <summary>Set from the most recent GRN line's UnitCost -- a simple latest-cost
/// approach, not a weighted average.</summary>
public decimal CostPrice { get; set; } = 0m;
public decimal SellingPrice { get; set; } = 0m;
public decimal QuantityOnHand { get; set; } = 0m;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+59
View File
@@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>Stock going out to a customer. Creating one decrements each line's
/// Product.QuantityOnHand -- the whole sale is rejected up front if any line would go
/// negative (no backorders).</summary>
public class Sale
{
[Key]
public Guid SaleId { get; set; }
public Guid FKClientId { get; set; }
[ForeignKey(nameof(FKClientId))]
public Client Client { get; set; }
[MaxLength(32)]
public string SaleNumber { get; set; } = string.Empty;
public DateTime SoldAt { get; set; } = DateTime.UtcNow;
/// <summary>"Cash" | "Bank" -- same convention as Booking.PaymentMethod.</summary>
[MaxLength(20)]
public string PaymentMethod { get; set; } = "Cash";
/// <summary>Optional link to a registered member, purely for identification -- mirrors
/// Booking.FKCustomerId (see BookingService.ResolveOptionalCustomerLinkAsync). A walk-in
/// buyer leaves this null and uses CustomerName instead.</summary>
public Guid? FKCustomerId { get; set; }
[MaxLength(128)]
public string? CustomerName { get; set; }
public decimal TotalAmount { get; set; } = 0m;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public virtual ICollection<SaleItem> Items { get; set; } = new List<SaleItem>();
}
public class SaleItem
{
[Key]
public Guid SaleItemId { get; set; }
public Guid FKSaleId { get; set; }
[ForeignKey(nameof(FKSaleId))]
public Sale? Sale { get; set; }
public Guid FKProductId { get; set; }
[ForeignKey(nameof(FKProductId))]
public Product? Product { get; set; }
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal { get; set; }
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>A stock adjustment -- expired, damaged, lost, sent back to the supplier, handed
/// back by a customer, or anything else (Category == "Other"). Direction depends on the
/// category: "CustomerReturn" is the one case that ADDS back to Product.QuantityOnHand (the
/// item physically comes back after an earlier sale already removed it); every other
/// category REMOVES from it (stock we currently hold being written off or sent away). Amount
/// is auto-computed (Quantity * Product.CostPrice at the moment of the return) for the
/// Profit &amp; Loss impact. FKSaleId/FKGrnId are both optional, independent traceability
/// links to the specific Sale or GRN this return relates to -- many returns (e.g. damaged
/// stock sitting on the shelf) don't trace back to either.</summary>
public class StockReturn
{
[Key]
public Guid StockReturnId { get; set; }
public Guid FKClientId { get; set; }
[ForeignKey(nameof(FKClientId))]
public Client Client { get; set; }
public Guid FKProductId { get; set; }
[ForeignKey(nameof(FKProductId))]
public Product? Product { get; set; }
public decimal Quantity { get; set; }
/// <summary>"Expired" | "Damaged" | "Lost" | "SupplierReturn" | "CustomerReturn" | "Other".
/// Only "CustomerReturn" adds back to stock -- see class doc comment.</summary>
[MaxLength(20)]
public string Category { get; set; } = "Other";
/// <summary>Optional -- e.g. a customer return that came from a specific sale.</summary>
public Guid? FKSaleId { get; set; }
[ForeignKey(nameof(FKSaleId))]
public Sale? Sale { get; set; }
/// <summary>Optional -- e.g. stock sent back to the supplier from a specific GRN.</summary>
public Guid? FKGrnId { get; set; }
[ForeignKey(nameof(FKGrnId))]
public GoodsReceivedNote? Grn { get; set; }
[MaxLength(300)]
public string? Notes { get; set; }
public DateTime ReturnedAt { get; set; } = DateTime.UtcNow;
/// <summary>Quantity * Product.CostPrice at the moment of the return -- snapshotted so
/// it doesn't drift later if CostPrice changes.</summary>
public decimal Amount { get; set; } = 0m;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+31
View File
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>Who stock is received from on a GoodsReceivedNote.</summary>
public class Supplier
{
[Key]
public Guid SupplierId { get; set; }
public Guid FKClientId { get; set; }
[ForeignKey(nameof(FKClientId))]
public Client Client { get; set; }
[MaxLength(128)]
public string Name { get; set; } = string.Empty;
[MaxLength(32)]
public string? Phone { get; set; }
[MaxLength(128)]
public string? Email { get; set; }
[MaxLength(300)]
public string? Address { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}