diff --git a/CentralDbContext.cs b/CentralDbContext.cs index 93aea5b..e6ae93b 100644 --- a/CentralDbContext.cs +++ b/CentralDbContext.cs @@ -152,6 +152,7 @@ namespace Fitness_core_BE new NavItem { Key = "memberships", Label = "Memberships", Href = "/dashboard/memberships", Icon = "CreditCard", SortOrder = 3, IsOwnerOnly = false }, new NavItem { Key = "bookings", Label = "Bookings", Href = "/dashboard/bookings", Icon = "CalendarClock", SortOrder = 4, IsOwnerOnly = false }, new NavItem { Key = "customer-payments", Label = "Customer Payments", ShortLabel = "Payments", Href = "/dashboard/customer-payments", Icon = "Receipt", SortOrder = 5, IsOwnerOnly = false }, + new NavItem { Key = "inventory", Label = "Inventory", Href = "/dashboard/inventory", Icon = "Package", SortOrder = 6, IsOwnerOnly = false }, new NavItem { Key = "workout-plans", Label = "Workout Plans", Href = "/dashboard/workout-plans", Icon = "Activity", SortOrder = 7, IsOwnerOnly = false }, new NavItem { Key = "meal-plans", Label = "Meal Plans", Href = "/dashboard/meal-plans", Icon = "Utensils", SortOrder = 8, IsOwnerOnly = false }, new NavItem { Key = "staff", Label = "Staff", Href = "/dashboard/staff", Icon = "UsersRound", SortOrder = 9, IsOwnerOnly = false }, diff --git a/Controllers/InventoryController.cs b/Controllers/InventoryController.cs new file mode 100644 index 0000000..17af1e0 --- /dev/null +++ b/Controllers/InventoryController.cs @@ -0,0 +1,174 @@ +using Fitness_core_BE.DTOs.Inventory; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Simple inventory: Products, Suppliers, GRN (stock in), Sales (stock out) and Returns + // (stock write-offs). All gated on the "inventory" nav permission. + [ApiController] + [Route("api/client/inventory")] + public class InventoryController(IInventoryService inventoryService) : ControllerBase + { + private readonly IInventoryService _inventoryService = inventoryService; + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpGet("products")] + public async Task>> GetProducts([FromQuery] bool includeInactive = false) + => Response(await _inventoryService.GetProducts(new { includeInactive }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpGet("products/{id:guid}")] + public async Task>> GetProductById(Guid id) + => Response(await _inventoryService.GetProductById(new { productId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpPost("products")] + public async Task>> CreateProduct([FromBody] CreateProductRequest request) + => Response(await _inventoryService.CreateProduct(new + { + name = request.Name, + unitOfMeasure = request.UnitOfMeasure, + sellingPrice = request.SellingPrice + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpPut("products/{id:guid}")] + public async Task>> UpdateProduct(Guid id, [FromBody] UpdateProductRequest request) + { + var payload = new Dictionary { ["productId"] = id }; + if (request.Name != null) payload["name"] = request.Name; + if (request.UnitOfMeasure != null) payload["unitOfMeasure"] = request.UnitOfMeasure; + if (request.SellingPrice.HasValue) payload["sellingPrice"] = request.SellingPrice.Value; + if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value; + + return Response(await _inventoryService.UpdateProduct(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpPost("products/{id:guid}/image")] + [RequestSizeLimit(6_000_000)] + [Consumes("multipart/form-data")] + public async Task>> UploadProductImage(Guid id, [FromForm] IFormFile image) + => Response(await _inventoryService.UploadProductImage(id, image, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpGet("suppliers")] + public async Task>> GetSuppliers([FromQuery] bool includeInactive = false) + => Response(await _inventoryService.GetSuppliers(new { includeInactive }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpPost("suppliers")] + public async Task>> CreateSupplier([FromBody] CreateSupplierRequest request) + => Response(await _inventoryService.CreateSupplier(new + { + name = request.Name, + phone = request.Phone, + email = request.Email, + address = request.Address + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpGet("grn")] + public async Task>> GetGrns() + => Response(await _inventoryService.GetGrns(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpGet("grn/{id:guid}")] + public async Task>> GetGrnById(Guid id) + => Response(await _inventoryService.GetGrnById(new { grnId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpPost("grn")] + public async Task>> CreateGrn([FromBody] CreateGrnRequest request) + => Response(await _inventoryService.CreateGrn(new + { + supplierId = request.SupplierId, + receivedAt = request.ReceivedAt?.ToString("o"), + notes = request.Notes, + items = request.Items.Select(i => new { productId = i.ProductId, quantity = i.Quantity, unitCost = i.UnitCost }) + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpGet("sales")] + public async Task>> GetSales() + => Response(await _inventoryService.GetSales(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpGet("sales/{id:guid}")] + public async Task>> GetSaleById(Guid id) + => Response(await _inventoryService.GetSaleById(new { saleId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpPost("sales")] + public async Task>> CreateSale([FromBody] CreateSaleRequest request) + => Response(await _inventoryService.CreateSale(new + { + soldAt = request.SoldAt?.ToString("o"), + paymentMethod = request.PaymentMethod, + customerId = request.CustomerId, + customerName = request.CustomerName, + items = request.Items.Select(i => new { productId = i.ProductId, quantity = i.Quantity, unitPrice = i.UnitPrice }) + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpGet("returns")] + public async Task>> GetStockReturns() + => Response(await _inventoryService.GetStockReturns(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpGet("returns/{id:guid}")] + public async Task>> GetStockReturnById(Guid id) + => Response(await _inventoryService.GetStockReturnById(new { stockReturnId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("inventory")] + [HttpPost("returns")] + public async Task>> CreateStockReturn([FromBody] CreateStockReturnRequest request) + => Response(await _inventoryService.CreateStockReturn(new + { + productId = request.ProductId, + quantity = request.Quantity, + category = request.Category, + saleId = request.SaleId, + grnId = request.GrnId, + notes = request.Notes, + returnedAt = request.ReturnedAt?.ToString("o") + }, HttpContext)); + } +} diff --git a/DTOs/Inventory/InventoryDtos.cs b/DTOs/Inventory/InventoryDtos.cs new file mode 100644 index 0000000..2ebed4f --- /dev/null +++ b/DTOs/Inventory/InventoryDtos.cs @@ -0,0 +1,106 @@ +namespace Fitness_core_BE.DTOs.Inventory +{ + public class ProductDto + { + public Guid ProductId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Sku { get; set; } + /// Base64 data URL, computed on read from ImageId -- null when no image is set. + public string? ImageUrl { get; set; } + public string UnitOfMeasure { get; set; } = string.Empty; + public decimal CostPrice { get; set; } + public decimal SellingPrice { get; set; } + public decimal QuantityOnHand { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + } + + /// One row in a Product's detail-page stock-movement history. + public class ProductMovementDto + { + /// "GRN" | "Sale" | "Return". + public string Type { get; set; } = string.Empty; + /// The GRN/Sale number, or the return's Category. + public string Reference { get; set; } = string.Empty; + /// Signed: positive for a GRN (stock in), negative for a Sale or Return (stock out). + public decimal QuantityChange { get; set; } + public DateTime Date { get; set; } + } + + public class ProductDetailDto : ProductDto + { + public List Movements { get; set; } = []; + } + + public class SupplierDto + { + public Guid SupplierId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Address { get; set; } + public bool IsActive { get; set; } + } + + public class GoodsReceivedNoteItemDto + { + public Guid ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitCost { get; set; } + public decimal LineTotal { get; set; } + } + + public class GoodsReceivedNoteDto + { + public Guid GrnId { get; set; } + public string GrnNumber { get; set; } = string.Empty; + /// Null when this GRN was recorded without a supplier. + public Guid? SupplierId { get; set; } + public string? SupplierName { get; set; } + public DateTime ReceivedAt { get; set; } + public string? Notes { get; set; } + public decimal TotalAmount { get; set; } + public List Items { get; set; } = []; + } + + public class SaleItemDto + { + public Guid ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } + public decimal LineTotal { get; set; } + } + + public class SaleDto + { + public Guid SaleId { get; set; } + public string SaleNumber { get; set; } = string.Empty; + public DateTime SoldAt { get; set; } + public string PaymentMethod { get; set; } = string.Empty; + public Guid? CustomerId { get; set; } + public string? CustomerName { get; set; } + public decimal TotalAmount { get; set; } + public List Items { get; set; } = []; + } + + public class StockReturnDto + { + public Guid StockReturnId { get; set; } + public Guid ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public string Category { get; set; } = string.Empty; + /// Optional traceability link -- which sale this return came from, if any. + public Guid? SaleId { get; set; } + public string? SaleNumber { get; set; } + /// Optional traceability link -- which GRN this return relates to, if any. + public Guid? GrnId { get; set; } + public string? GrnNumber { get; set; } + public string? Notes { get; set; } + public DateTime ReturnedAt { get; set; } + /// Quantity * Product.CostPrice at the moment of the return -- auto-computed. + public decimal Amount { get; set; } + } +} diff --git a/DTOs/Inventory/InventoryRequests.cs b/DTOs/Inventory/InventoryRequests.cs new file mode 100644 index 0000000..337d0b4 --- /dev/null +++ b/DTOs/Inventory/InventoryRequests.cs @@ -0,0 +1,107 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.Inventory +{ + public class CreateProductRequest + { + [Required] + public string Name { get; set; } = string.Empty; + public string UnitOfMeasure { get; set; } = "pcs"; + + [Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "SellingPrice cannot be negative")] + public decimal SellingPrice { get; set; } = 0m; + } + + /// Sku is system-generated at creation and never included here -- it can't be edited. + public class UpdateProductRequest + { + public string? Name { get; set; } + public string? UnitOfMeasure { get; set; } + public decimal? SellingPrice { get; set; } + public bool? IsActive { get; set; } + } + + public class CreateSupplierRequest + { + [Required] + public string Name { get; set; } = string.Empty; + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Address { get; set; } + } + + public class GrnItemRequest + { + [Required] + public Guid ProductId { get; set; } + + [Range(0.0001, double.MaxValue, ErrorMessage = "Quantity must be greater than 0")] + public decimal Quantity { get; set; } + + [Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "UnitCost cannot be negative")] + public decimal UnitCost { get; set; } + } + + public class CreateGrnRequest + { + /// Optional -- some GRNs don't have a supplier on file. + public Guid? SupplierId { get; set; } + public DateTime? ReceivedAt { get; set; } + public string? Notes { get; set; } + + [Required, MinLength(1, ErrorMessage = "At least one item is required")] + public List Items { get; set; } = []; + } + + public class SaleItemRequest + { + [Required] + public Guid ProductId { get; set; } + + [Range(0.0001, double.MaxValue, ErrorMessage = "Quantity must be greater than 0")] + public decimal Quantity { get; set; } + + [Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "UnitPrice cannot be negative")] + public decimal UnitPrice { get; set; } + } + + public class CreateSaleRequest + { + public DateTime? SoldAt { get; set; } + + /// "Cash" | "Bank". + [Required] + public string PaymentMethod { get; set; } = "Cash"; + + public Guid? CustomerId { get; set; } + public string? CustomerName { get; set; } + + [Required, MinLength(1, ErrorMessage = "At least one item is required")] + public List Items { get; set; } = []; + } + + public class CreateStockReturnRequest + { + [Required] + public Guid ProductId { get; set; } + + [Range(0.0001, double.MaxValue, ErrorMessage = "Quantity must be greater than 0")] + public decimal Quantity { get; set; } + + /// "Expired" | "Damaged" | "Lost" | "SupplierReturn" | "CustomerReturn" | "Other". + /// Only "CustomerReturn" adds back to stock; every other category removes from it. + [Required] + public string Category { get; set; } = "Other"; + + /// Optional traceability link -- e.g. a customer return that came from a + /// specific sale. Many returns (damaged shelf stock, etc.) don't have one. + public Guid? SaleId { get; set; } + + /// Optional traceability link -- e.g. stock sent back to the supplier from a + /// specific GRN. Many returns don't have one. + public Guid? GrnId { get; set; } + + public string? Notes { get; set; } + public DateTime? ReturnedAt { get; set; } + } +} diff --git a/Interfaces/IInventoryRepository.cs b/Interfaces/IInventoryRepository.cs new file mode 100644 index 0000000..1830df1 --- /dev/null +++ b/Interfaces/IInventoryRepository.cs @@ -0,0 +1,44 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IInventoryRepository + { + // Products + Task> GetProducts(Guid clientId, bool includeInactive = false); + Task GetProductById(Guid productId); + Task CreateProduct(Product product); + Task UpdateProduct(Product product); + /// Next sequential number for this client, e.g. 3 -> "PRD-0004" -- Sku is + /// system-generated, never user-entered. + Task GetNextProductSequence(Guid clientId); + + // Product detail page: stock-movement history (GRN-in, Sale-out, Return-out). + Task> GetGrnItemsForProduct(Guid productId); + Task> GetSaleItemsForProduct(Guid productId); + Task> GetStockReturnsForProduct(Guid productId); + + // Suppliers + Task> GetSuppliers(Guid clientId, bool includeInactive = false); + Task GetSupplierById(Guid supplierId); + Task CreateSupplier(Supplier supplier); + + // GRN + Task CreateGrn(GoodsReceivedNote grn); + Task> GetGrns(Guid clientId); + Task GetGrnById(Guid grnId); + /// Next sequential number for this client, e.g. 3 -> "GRN-0004". + Task GetNextGrnSequence(Guid clientId); + + // Sales + Task CreateSale(Sale sale); + Task> GetSales(Guid clientId); + Task GetSaleById(Guid saleId); + Task GetNextSaleSequence(Guid clientId); + + // Returns + Task CreateStockReturn(StockReturn stockReturn); + Task> GetStockReturns(Guid clientId); + Task GetStockReturnById(Guid stockReturnId); + } +} diff --git a/Interfaces/Services/IInventoryService.cs b/Interfaces/Services/IInventoryService.cs new file mode 100644 index 0000000..e4ed98c --- /dev/null +++ b/Interfaces/Services/IInventoryService.cs @@ -0,0 +1,26 @@ +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IInventoryService + { + Task GetProducts(object payload, HttpContext httpContext); + Task GetProductById(object payload, HttpContext httpContext); + Task CreateProduct(object payload, HttpContext httpContext); + Task UpdateProduct(object payload, HttpContext httpContext); + Task UploadProductImage(Guid productId, IFormFile image, HttpContext httpContext); + + Task GetSuppliers(object payload, HttpContext httpContext); + Task CreateSupplier(object payload, HttpContext httpContext); + + Task CreateGrn(object payload, HttpContext httpContext); + Task GetGrns(object payload, HttpContext httpContext); + Task GetGrnById(object payload, HttpContext httpContext); + + Task CreateSale(object payload, HttpContext httpContext); + Task GetSales(object payload, HttpContext httpContext); + Task GetSaleById(object payload, HttpContext httpContext); + + Task CreateStockReturn(object payload, HttpContext httpContext); + Task GetStockReturns(object payload, HttpContext httpContext); + Task GetStockReturnById(object payload, HttpContext httpContext); + } +} diff --git a/Models/GoodsReceivedNote.cs b/Models/GoodsReceivedNote.cs new file mode 100644 index 0000000..8f43307 --- /dev/null +++ b/Models/GoodsReceivedNote.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// 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). + 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 Items { get; set; } = new List(); + } + + 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; } + } +} diff --git a/Models/Product.cs b/Models/Product.cs new file mode 100644 index 0000000..e10faf8 --- /dev/null +++ b/Models/Product.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// 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. + 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; } + + /// Relative /Uploads path to the product's photo (same convention as + /// ClientBusiness.BusinessLogoId) -- null when no image has been uploaded. + [MaxLength(300)] + public string? ImageId { get; set; } + + [MaxLength(20)] + public string UnitOfMeasure { get; set; } = "pcs"; + + /// Set from the most recent GRN line's UnitCost -- a simple latest-cost + /// approach, not a weighted average. + 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; + } +} diff --git a/Models/Sale.cs b/Models/Sale.cs new file mode 100644 index 0000000..cc81b25 --- /dev/null +++ b/Models/Sale.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// 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). + 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; + + /// "Cash" | "Bank" -- same convention as Booking.PaymentMethod. + [MaxLength(20)] + public string PaymentMethod { get; set; } = "Cash"; + + /// 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. + 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 Items { get; set; } = new List(); + } + + 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; } + } +} diff --git a/Models/StockReturn.cs b/Models/StockReturn.cs new file mode 100644 index 0000000..0c60722 --- /dev/null +++ b/Models/StockReturn.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// 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 & 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. + 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; } + + /// "Expired" | "Damaged" | "Lost" | "SupplierReturn" | "CustomerReturn" | "Other". + /// Only "CustomerReturn" adds back to stock -- see class doc comment. + [MaxLength(20)] + public string Category { get; set; } = "Other"; + + /// Optional -- e.g. a customer return that came from a specific sale. + public Guid? FKSaleId { get; set; } + [ForeignKey(nameof(FKSaleId))] + public Sale? Sale { get; set; } + + /// Optional -- e.g. stock sent back to the supplier from a specific GRN. + 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; + + /// Quantity * Product.CostPrice at the moment of the return -- snapshotted so + /// it doesn't drift later if CostPrice changes. + public decimal Amount { get; set; } = 0m; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/Supplier.cs b/Models/Supplier.cs new file mode 100644 index 0000000..d21bf4b --- /dev/null +++ b/Models/Supplier.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// Who stock is received from on a GoodsReceivedNote. + 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; + } +} diff --git a/Program.cs b/Program.cs index a8e4040..1333df1 100644 --- a/Program.cs +++ b/Program.cs @@ -142,6 +142,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -163,6 +164,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Repos/InventoryRepository.cs b/Repos/InventoryRepository.cs new file mode 100644 index 0000000..3cc1398 --- /dev/null +++ b/Repos/InventoryRepository.cs @@ -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> 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 GetProductById(Guid productId) + => _context.Products.FirstOrDefaultAsync(p => p.ProductId == productId); + + public async Task 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 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 GetNextProductSequence(Guid clientId) + => await _context.Products.CountAsync(p => p.FKClientId == clientId) + 1; + + public Task> GetGrnItemsForProduct(Guid productId) + => _context.GoodsReceivedNoteItems + .Include(i => i.Grn) + .Where(i => i.FKProductId == productId) + .OrderByDescending(i => i.Grn!.ReceivedAt) + .ToListAsync(); + + public Task> GetSaleItemsForProduct(Guid productId) + => _context.SaleItems + .Include(i => i.Sale) + .Where(i => i.FKProductId == productId) + .OrderByDescending(i => i.Sale!.SoldAt) + .ToListAsync(); + + public Task> GetStockReturnsForProduct(Guid productId) + => _context.StockReturns + .Where(r => r.FKProductId == productId) + .OrderByDescending(r => r.ReturnedAt) + .ToListAsync(); + + // --- Suppliers --- + + public async Task> 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 GetSupplierById(Guid supplierId) + => _context.Suppliers.FirstOrDefaultAsync(s => s.SupplierId == supplierId); + + public async Task CreateSupplier(Supplier supplier) + { + supplier.SupplierId = Guid.NewGuid(); + supplier.CreatedAt = DateTime.UtcNow; + supplier.IsActive = true; + + _context.Suppliers.Add(supplier); + return supplier; + } + + // --- GRN --- + + public async Task 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> 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 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 GetNextGrnSequence(Guid clientId) + => await _context.GoodsReceivedNotes.CountAsync(g => g.FKClientId == clientId) + 1; + + // --- Sales --- + + public async Task 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> 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 GetSaleById(Guid saleId) + => _context.Sales + .Include(s => s.Items).ThenInclude(i => i.Product) + .FirstOrDefaultAsync(s => s.SaleId == saleId); + + public async Task GetNextSaleSequence(Guid clientId) + => await _context.Sales.CountAsync(s => s.FKClientId == clientId) + 1; + + // --- Returns --- + + public async Task CreateStockReturn(StockReturn stockReturn) + { + stockReturn.StockReturnId = Guid.NewGuid(); + stockReturn.CreatedAt = DateTime.UtcNow; + + _context.StockReturns.Add(stockReturn); + return stockReturn; + } + + public Task> 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 GetStockReturnById(Guid stockReturnId) + => _context.StockReturns + .Include(r => r.Product) + .Include(r => r.Sale) + .Include(r => r.Grn) + .FirstOrDefaultAsync(r => r.StockReturnId == stockReturnId); + } +} diff --git a/Repos/ProfitLossRepository.cs b/Repos/ProfitLossRepository.cs index 032b6d1..0777351 100644 --- a/Repos/ProfitLossRepository.cs +++ b/Repos/ProfitLossRepository.cs @@ -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; } diff --git a/Services/InventoryService.cs b/Services/InventoryService.cs new file mode 100644 index 0000000..d20fe43 --- /dev/null +++ b/Services/InventoryService.cs @@ -0,0 +1,673 @@ +using Fitness_core_BE.DTOs.Inventory; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using System.Text.Json; + +namespace Fitness_core_BE.Services +{ + public class InventoryService : IInventoryService + { + private static readonly string[] ValidReturnCategories = + ["Expired", "Damaged", "Lost", "SupplierReturn", "CustomerReturn", "Other"]; + + private readonly IInventoryRepository _inventoryRepository; + private readonly ICustomerRepository _customerRepository; + private readonly IUnitOfWork _uow; + private readonly UserRoleHelper _userRoleHelper; + + public InventoryService( + IInventoryRepository inventoryRepository, + ICustomerRepository customerRepository, + IUnitOfWork uow, + UserRoleHelper userRoleHelper) + { + _inventoryRepository = inventoryRepository; + _customerRepository = customerRepository; + _uow = uow; + _userRoleHelper = userRoleHelper; + } + + // --- Products --- + + public async Task GetProducts(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean(); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var products = await _inventoryRepository.GetProducts(clientId, includeInactive); + + return (await Task.WhenAll(products.Select(MapToDtoAsync))).ToList(); + } + + public async Task GetProductById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var productId = Guid.Parse(data["productId"].GetString()!); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var product = await _inventoryRepository.GetProductById(productId); + if (product == null || product.FKClientId != clientId) + throw new KeyNotFoundException("Product not found"); + + var grnItems = await _inventoryRepository.GetGrnItemsForProduct(productId); + var saleItems = await _inventoryRepository.GetSaleItemsForProduct(productId); + var returns = await _inventoryRepository.GetStockReturnsForProduct(productId); + + var movements = new List(); + movements.AddRange(grnItems.Select(i => new ProductMovementDto + { + Type = "GRN", + Reference = i.Grn?.GrnNumber ?? string.Empty, + QuantityChange = i.Quantity, + Date = i.Grn?.ReceivedAt ?? DateTime.UtcNow + })); + movements.AddRange(saleItems.Select(i => new ProductMovementDto + { + Type = "Sale", + Reference = i.Sale?.SaleNumber ?? string.Empty, + QuantityChange = -i.Quantity, + Date = i.Sale?.SoldAt ?? DateTime.UtcNow + })); + movements.AddRange(returns.Select(r => new ProductMovementDto + { + Type = "Return", + Reference = r.Category, + // CustomerReturn is the one category that adds back to stock -- see + // StockReturn's doc comment. + QuantityChange = r.Category == "CustomerReturn" ? r.Quantity : -r.Quantity, + Date = r.ReturnedAt + })); + movements = movements.OrderByDescending(m => m.Date).ToList(); + + var dto = await MapToDtoAsync(product); + return new ProductDetailDto + { + ProductId = dto.ProductId, + Name = dto.Name, + Sku = dto.Sku, + ImageUrl = dto.ImageUrl, + UnitOfMeasure = dto.UnitOfMeasure, + CostPrice = dto.CostPrice, + SellingPrice = dto.SellingPrice, + QuantityOnHand = dto.QuantityOnHand, + IsActive = dto.IsActive, + CreatedAt = dto.CreatedAt, + Movements = movements + }; + } + + public async Task CreateProduct(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required"); + + var sellingPrice = data.ContainsKey("sellingPrice") ? data["sellingPrice"].GetDecimal() : 0m; + if (sellingPrice < 0) throw new ArgumentException("sellingPrice cannot be negative"); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + var product = new Product + { + FKClientId = clientId, + Name = data["name"].GetString() ?? "", + UnitOfMeasure = data.ContainsKey("unitOfMeasure") ? data["unitOfMeasure"].GetString() ?? "pcs" : "pcs", + SellingPrice = sellingPrice + }; + + var sequence = await _inventoryRepository.GetNextProductSequence(clientId); + product.Sku = $"PRD-{sequence:D4}"; + + var result = await _inventoryRepository.CreateProduct(product); + await _uow.CommitAsync(); + + return await MapToDtoAsync(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdateProduct(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var productId = Guid.Parse(data["productId"].GetString()!); + + var existing = await _inventoryRepository.GetProductById(productId) + ?? throw new KeyNotFoundException("Product not found"); + + // Sku is system-generated at creation and never editable here. + if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name; + if (data.ContainsKey("unitOfMeasure")) existing.UnitOfMeasure = data["unitOfMeasure"].GetString() ?? existing.UnitOfMeasure; + if (data.ContainsKey("sellingPrice")) + { + var price = data["sellingPrice"].GetDecimal(); + if (price < 0) throw new ArgumentException("sellingPrice cannot be negative"); + existing.SellingPrice = price; + } + if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean(); + + var result = await _inventoryRepository.UpdateProduct(existing); + await _uow.CommitAsync(); + + return await MapToDtoAsync(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UploadProductImage(Guid productId, IFormFile image, HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var product = await _inventoryRepository.GetProductById(productId); + if (product == null || product.FKClientId != clientId) + throw new KeyNotFoundException("Product not found"); + + if (image == null || image.Length == 0) + throw new BadRequestException("No image file provided."); + + string extension = Path.GetExtension(image.FileName).ToLowerInvariant(); + string[] allowed = { ".jpg", ".jpeg", ".png", ".webp" }; + if (!allowed.Contains(extension)) + throw new BadRequestException("Image must be a jpg, png or webp file."); + if (image.Length > 5 * 1024 * 1024) + throw new BadRequestException("Image too large (max 5MB)."); + + string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", "product-images", clientId.ToString())); + if (!Directory.Exists(uploadsRoot)) + Directory.CreateDirectory(uploadsRoot); + + string filename = $"{productId}_{DateTime.UtcNow:yyyyMMdd_HHmmss}{extension}"; + string filePath = Path.Combine(uploadsRoot, filename); + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await image.CopyToAsync(stream); + } + + await _uow.BeginAsync(); + try + { + product.ImageId = $"/Uploads/product-images/{clientId}/{Uri.EscapeDataString(filename)}"; + await _uow.CommitAsync(); + return await MapToDtoAsync(product); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // --- Suppliers --- + + public async Task GetSuppliers(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean(); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var suppliers = await _inventoryRepository.GetSuppliers(clientId, includeInactive); + + return suppliers.Select(MapToDto).ToList(); + } + + public async Task CreateSupplier(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required"); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + var supplier = new Supplier + { + FKClientId = clientId, + Name = data["name"].GetString() ?? "", + Phone = data.ContainsKey("phone") && data["phone"].ValueKind != JsonValueKind.Null ? data["phone"].GetString() : null, + Email = data.ContainsKey("email") && data["email"].ValueKind != JsonValueKind.Null ? data["email"].GetString() : null, + Address = data.ContainsKey("address") && data["address"].ValueKind != JsonValueKind.Null ? data["address"].GetString() : null + }; + + var result = await _inventoryRepository.CreateSupplier(supplier); + await _uow.CommitAsync(); + + return MapToDto(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // --- GRN --- + + public async Task CreateGrn(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + // Optional -- some GRNs don't have a supplier on file. + Supplier? supplier = null; + Guid? supplierId = data.ContainsKey("supplierId") && data["supplierId"].ValueKind != JsonValueKind.Null + ? Guid.Parse(data["supplierId"].GetString()!) + : null; + if (supplierId.HasValue) + { + supplier = await _inventoryRepository.GetSupplierById(supplierId.Value); + if (supplier == null || supplier.FKClientId != clientId) + throw new KeyNotFoundException("Supplier not found"); + } + + if (!data.ContainsKey("items") || data["items"].ValueKind != JsonValueKind.Array) + throw new ArgumentException("At least one item is required"); + + var receivedAt = data.ContainsKey("receivedAt") && data["receivedAt"].ValueKind != JsonValueKind.Null + ? DateTime.Parse(data["receivedAt"].GetString()!).ToUniversalTime() + : DateTime.UtcNow; + + var grn = new GoodsReceivedNote + { + FKClientId = clientId, + FKSupplierId = supplierId, + ReceivedAt = receivedAt, + Notes = data.ContainsKey("notes") && data["notes"].ValueKind != JsonValueKind.Null ? data["notes"].GetString() : null + }; + + decimal total = 0m; + var itemsArray = data["items"].EnumerateArray().ToList(); + if (itemsArray.Count == 0) throw new ArgumentException("At least one item is required"); + + foreach (var itemEl in itemsArray) + { + var productId = Guid.Parse(itemEl.GetProperty("productId").GetString()!); + var quantity = itemEl.GetProperty("quantity").GetDecimal(); + var unitCost = itemEl.GetProperty("unitCost").GetDecimal(); + + if (quantity <= 0) throw new ArgumentException("Quantity must be greater than 0"); + if (unitCost < 0) throw new ArgumentException("UnitCost cannot be negative"); + + var product = await _inventoryRepository.GetProductById(productId); + if (product == null || product.FKClientId != clientId) + throw new KeyNotFoundException("Product not found"); + + var lineTotal = quantity * unitCost; + grn.Items.Add(new GoodsReceivedNoteItem + { + FKProductId = productId, + Quantity = quantity, + UnitCost = unitCost, + LineTotal = lineTotal + }); + + product.QuantityOnHand += quantity; + product.CostPrice = unitCost; + total += lineTotal; + } + + grn.TotalAmount = total; + var sequence = await _inventoryRepository.GetNextGrnSequence(clientId); + grn.GrnNumber = $"GRN-{sequence:D4}"; + + var result = await _inventoryRepository.CreateGrn(grn); + await _uow.CommitAsync(); + + result.Supplier = supplier; + return MapToDto(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetGrns(object payload, HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var grns = await _inventoryRepository.GetGrns(clientId); + return grns.Select(MapToDto).ToList(); + } + + public async Task GetGrnById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var grnId = Guid.Parse(data["grnId"].GetString()!); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var grn = await _inventoryRepository.GetGrnById(grnId); + if (grn == null || grn.FKClientId != clientId) + throw new KeyNotFoundException("GRN not found"); + + return MapToDto(grn); + } + + // --- Sales --- + + public async Task CreateSale(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + var paymentMethod = data.ContainsKey("paymentMethod") && data["paymentMethod"].ValueKind != JsonValueKind.Null + ? data["paymentMethod"].GetString() ?? "Cash" : "Cash"; + + var customerId = await ResolveOptionalCustomerLinkAsync(data); + + if (!data.ContainsKey("items") || data["items"].ValueKind != JsonValueKind.Array) + throw new ArgumentException("At least one item is required"); + + var soldAt = data.ContainsKey("soldAt") && data["soldAt"].ValueKind != JsonValueKind.Null + ? DateTime.Parse(data["soldAt"].GetString()!).ToUniversalTime() + : DateTime.UtcNow; + + var sale = new Sale + { + FKClientId = clientId, + SoldAt = soldAt, + PaymentMethod = paymentMethod, + FKCustomerId = customerId, + CustomerName = data.ContainsKey("customerName") && data["customerName"].ValueKind != JsonValueKind.Null + ? data["customerName"].GetString() : null + }; + + decimal total = 0m; + var itemsArray = data["items"].EnumerateArray().ToList(); + if (itemsArray.Count == 0) throw new ArgumentException("At least one item is required"); + + // Load + validate every line before mutating stock, so a short line rejects the + // whole sale rather than partially decrementing earlier lines. + var lines = new List<(Product Product, decimal Quantity, decimal UnitPrice)>(); + foreach (var itemEl in itemsArray) + { + var productId = Guid.Parse(itemEl.GetProperty("productId").GetString()!); + var quantity = itemEl.GetProperty("quantity").GetDecimal(); + var unitPrice = itemEl.GetProperty("unitPrice").GetDecimal(); + + if (quantity <= 0) throw new ArgumentException("Quantity must be greater than 0"); + if (unitPrice < 0) throw new ArgumentException("UnitPrice cannot be negative"); + + var product = await _inventoryRepository.GetProductById(productId); + if (product == null || product.FKClientId != clientId) + throw new KeyNotFoundException("Product not found"); + if (product.QuantityOnHand < quantity) + throw new ArgumentException($"Not enough stock for \"{product.Name}\" (have {product.QuantityOnHand}, need {quantity})"); + + lines.Add((product, quantity, unitPrice)); + } + + foreach (var (product, quantity, unitPrice) in lines) + { + var lineTotal = quantity * unitPrice; + sale.Items.Add(new SaleItem + { + FKProductId = product.ProductId, + Quantity = quantity, + UnitPrice = unitPrice, + LineTotal = lineTotal + }); + + product.QuantityOnHand -= quantity; + total += lineTotal; + } + + sale.TotalAmount = total; + var sequence = await _inventoryRepository.GetNextSaleSequence(clientId); + sale.SaleNumber = $"SALE-{sequence:D4}"; + + var result = await _inventoryRepository.CreateSale(sale); + await _uow.CommitAsync(); + + return MapToDto(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetSales(object payload, HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var sales = await _inventoryRepository.GetSales(clientId); + return sales.Select(MapToDto).ToList(); + } + + public async Task GetSaleById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var saleId = Guid.Parse(data["saleId"].GetString()!); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var sale = await _inventoryRepository.GetSaleById(saleId); + if (sale == null || sale.FKClientId != clientId) + throw new KeyNotFoundException("Sale not found"); + + return MapToDto(sale); + } + + // --- Returns --- + + public async Task CreateStockReturn(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + var productId = Guid.Parse(data["productId"].GetString()!); + var quantity = data["quantity"].GetDecimal(); + if (quantity <= 0) throw new ArgumentException("Quantity must be greater than 0"); + + var category = data["category"].GetString() ?? "Other"; + if (!ValidReturnCategories.Contains(category)) + throw new ArgumentException($"category must be one of: {string.Join(", ", ValidReturnCategories)}"); + + var product = await _inventoryRepository.GetProductById(productId); + if (product == null || product.FKClientId != clientId) + throw new KeyNotFoundException("Product not found"); + + // CustomerReturn is the one category that adds back to stock -- the item + // physically comes back after an earlier sale already removed it, so there's no + // "enough stock on hand" requirement (it may well be at 0). Every other category + // removes from stock we currently hold, so it does require enough on hand. + var addsBackToStock = category == "CustomerReturn"; + if (!addsBackToStock && product.QuantityOnHand < quantity) + throw new ArgumentException($"Not enough stock for \"{product.Name}\" (have {product.QuantityOnHand}, need {quantity})"); + + // Optional traceability -- most returns have neither. Only validated for + // existence/ownership, not cross-checked against the product/quantity. + Guid? saleId = data.ContainsKey("saleId") && data["saleId"].ValueKind != JsonValueKind.Null + ? Guid.Parse(data["saleId"].GetString()!) : null; + Sale? sale = null; + if (saleId.HasValue) + { + sale = await _inventoryRepository.GetSaleById(saleId.Value); + if (sale == null || sale.FKClientId != clientId) + throw new KeyNotFoundException("Sale not found"); + } + + Guid? grnId = data.ContainsKey("grnId") && data["grnId"].ValueKind != JsonValueKind.Null + ? Guid.Parse(data["grnId"].GetString()!) : null; + GoodsReceivedNote? grn = null; + if (grnId.HasValue) + { + grn = await _inventoryRepository.GetGrnById(grnId.Value); + if (grn == null || grn.FKClientId != clientId) + throw new KeyNotFoundException("GRN not found"); + } + + var returnedAt = data.ContainsKey("returnedAt") && data["returnedAt"].ValueKind != JsonValueKind.Null + ? DateTime.Parse(data["returnedAt"].GetString()!).ToUniversalTime() + : DateTime.UtcNow; + + var stockReturn = new StockReturn + { + FKClientId = clientId, + FKProductId = productId, + Quantity = quantity, + Category = category, + FKSaleId = saleId, + FKGrnId = grnId, + Notes = data.ContainsKey("notes") && data["notes"].ValueKind != JsonValueKind.Null ? data["notes"].GetString() : null, + ReturnedAt = returnedAt, + Amount = quantity * product.CostPrice + }; + + product.QuantityOnHand += addsBackToStock ? quantity : -quantity; + + var result = await _inventoryRepository.CreateStockReturn(stockReturn); + await _uow.CommitAsync(); + + result.Product = product; + result.Sale = sale; + result.Grn = grn; + return MapToDto(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetStockReturns(object payload, HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var returns = await _inventoryRepository.GetStockReturns(clientId); + return returns.Select(MapToDto).ToList(); + } + + public async Task GetStockReturnById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var stockReturnId = Guid.Parse(data["stockReturnId"].GetString()!); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var stockReturn = await _inventoryRepository.GetStockReturnById(stockReturnId); + if (stockReturn == null || stockReturn.FKClientId != clientId) + throw new KeyNotFoundException("Return not found"); + + return MapToDto(stockReturn); + } + + /// Staff optionally picking an existing registered member to attach to a sale + /// (search-and-pick, not free-typed) -- purely for identification, mirrors + /// BookingService.ResolveOptionalCustomerLinkAsync exactly. + private async Task ResolveOptionalCustomerLinkAsync(Dictionary data) + { + if (!data.ContainsKey("customerId") || data["customerId"].ValueKind == JsonValueKind.Null) + return null; + + var customerId = Guid.Parse(data["customerId"].GetString()!); + _ = await _customerRepository.GetTenantCustomerLinkById(customerId) + ?? throw new KeyNotFoundException("Customer not found"); + return customerId; + } + + // ImageId is a relative /Uploads path (same convention as ClientBusiness.BusinessLogoId) + // with no static-file serving configured for it, so it's inlined as a base64 data URL. + private static async Task MapToDtoAsync(Product p) => new() + { + ProductId = p.ProductId, + Name = p.Name, + Sku = p.Sku, + ImageUrl = await BusinessLogoHelper.ReadAsDataUrlAsync(p.ImageId), + UnitOfMeasure = p.UnitOfMeasure, + CostPrice = p.CostPrice, + SellingPrice = p.SellingPrice, + QuantityOnHand = p.QuantityOnHand, + IsActive = p.IsActive, + CreatedAt = p.CreatedAt + }; + + private static SupplierDto MapToDto(Supplier s) => new() + { + SupplierId = s.SupplierId, + Name = s.Name, + Phone = s.Phone, + Email = s.Email, + Address = s.Address, + IsActive = s.IsActive + }; + + private static GoodsReceivedNoteDto MapToDto(GoodsReceivedNote g) => new() + { + GrnId = g.GrnId, + GrnNumber = g.GrnNumber, + SupplierId = g.FKSupplierId, + SupplierName = g.Supplier?.Name, + ReceivedAt = g.ReceivedAt, + Notes = g.Notes, + TotalAmount = g.TotalAmount, + Items = g.Items.Select(i => new GoodsReceivedNoteItemDto + { + ProductId = i.FKProductId, + ProductName = i.Product?.Name ?? string.Empty, + Quantity = i.Quantity, + UnitCost = i.UnitCost, + LineTotal = i.LineTotal + }).ToList() + }; + + private static SaleDto MapToDto(Sale s) => new() + { + SaleId = s.SaleId, + SaleNumber = s.SaleNumber, + SoldAt = s.SoldAt, + PaymentMethod = s.PaymentMethod, + CustomerId = s.FKCustomerId, + CustomerName = s.CustomerName, + TotalAmount = s.TotalAmount, + Items = s.Items.Select(i => new SaleItemDto + { + ProductId = i.FKProductId, + ProductName = i.Product?.Name ?? string.Empty, + Quantity = i.Quantity, + UnitPrice = i.UnitPrice, + LineTotal = i.LineTotal + }).ToList() + }; + + private static StockReturnDto MapToDto(StockReturn r) => new() + { + StockReturnId = r.StockReturnId, + ProductId = r.FKProductId, + ProductName = r.Product?.Name ?? string.Empty, + Quantity = r.Quantity, + Category = r.Category, + SaleId = r.FKSaleId, + SaleNumber = r.Sale?.SaleNumber, + GrnId = r.FKGrnId, + GrnNumber = r.Grn?.GrnNumber, + Notes = r.Notes, + ReturnedAt = r.ReturnedAt, + Amount = r.Amount + }; + } +} diff --git a/TenantDbContext.cs b/TenantDbContext.cs index 8e37a6c..c6fc394 100644 --- a/TenantDbContext.cs +++ b/TenantDbContext.cs @@ -82,6 +82,16 @@ namespace Fitness_core_BE // Models/BookingInvoice.cs. Deliberately separate from CustomerInvoice (membership-only). public DbSet BookingInvoices => Set(); + // Simple inventory: Products, Suppliers, GRN (stock in), Sales (stock out), Returns + // (stock write-offs) -- see each model's own doc comment. + public DbSet Products => Set(); + public DbSet Suppliers => Set(); + public DbSet GoodsReceivedNotes => Set(); + public DbSet GoodsReceivedNoteItems => Set(); + public DbSet Sales => Set(); + public DbSet SaleItems => Set(); + public DbSet StockReturns => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -191,6 +201,11 @@ namespace Fitness_core_BE modelBuilder.Entity().Ignore(i => i.Client); modelBuilder.Entity().Ignore(t => t.Client); modelBuilder.Entity().Ignore(l => l.Client); + modelBuilder.Entity().Ignore(p => p.Client); + modelBuilder.Entity().Ignore(s => s.Client); + modelBuilder.Entity().Ignore(g => g.Client); + modelBuilder.Entity().Ignore(s => s.Client); + modelBuilder.Entity().Ignore(r => r.Client); // A session's capacity is enforced by counting non-cancelled bookings against it at // booking time, not by a unique index -- multiple guests/members can share one slot @@ -286,6 +301,45 @@ namespace Fitness_core_BE modelBuilder.Entity() .HasIndex(i => new { i.FKClientId, i.IssuedAt }); + // Inventory: GRN/Sale items cascade with their header; Return/GRN/Sale restrict + // deleting a Product/Supplier that already has history (there's no delete endpoint + // for either, but Restrict keeps the data model honest either way). + modelBuilder.Entity() + .HasIndex(p => new { p.FKClientId, p.Name }); + modelBuilder.Entity() + .HasIndex(s => new { s.FKClientId, s.Name }); + modelBuilder.Entity() + .HasIndex(g => new { g.FKClientId, g.ReceivedAt }); + modelBuilder.Entity() + .HasOne(g => g.Supplier).WithMany() + .HasForeignKey(g => g.FKSupplierId).OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(i => i.Grn).WithMany(g => g.Items) + .HasForeignKey(i => i.FKGrnId).OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(i => i.Product).WithMany() + .HasForeignKey(i => i.FKProductId).OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasIndex(s => new { s.FKClientId, s.SoldAt }); + modelBuilder.Entity() + .HasOne(i => i.Sale).WithMany(s => s.Items) + .HasForeignKey(i => i.FKSaleId).OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(i => i.Product).WithMany() + .HasForeignKey(i => i.FKProductId).OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasIndex(r => new { r.FKClientId, r.ReturnedAt }); + modelBuilder.Entity() + .HasOne(r => r.Product).WithMany() + .HasForeignKey(r => r.FKProductId).OnDelete(DeleteBehavior.Restrict); + // Optional traceability links -- most returns have neither. + modelBuilder.Entity() + .HasOne(r => r.Sale).WithMany() + .HasForeignKey(r => r.FKSaleId).OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(r => r.Grn).WithMany() + .HasForeignKey(r => r.FKGrnId).OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() .HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });